aboutsummaryrefslogtreecommitdiff
path: root/src/app/(main)/websites/[websiteId]
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/(main)/websites/[websiteId]')
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution.tsx128
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx63
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/attribution/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx91
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx51
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx46
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/breakdown/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx134
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelAddButton.tsx28
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx141
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx36
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/funnels/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx99
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/goals/GoalAddButton.tsx28
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/goals/GoalEditForm.tsx104
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx36
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/goals/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css267
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx294
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx67
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/retention/Retention.tsx140
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage.tsx22
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/retention/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx152
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx18
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx21
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/revenue/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/utm/UTM.tsx71
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage.tsx18
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/utm/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/ExpandedViewModal.tsx52
-rw-r--r--src/app/(main)/websites/[websiteId]/WebsiteChart.tsx61
-rw-r--r--src/app/(main)/websites/[websiteId]/WebsiteControls.tsx40
-rw-r--r--src/app/(main)/websites/[websiteId]/WebsiteExpandedMenu.tsx183
-rw-r--r--src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx57
-rw-r--r--src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx57
-rw-r--r--src/app/(main)/websites/[websiteId]/WebsiteLayout.tsx30
-rw-r--r--src/app/(main)/websites/[websiteId]/WebsiteMenu.tsx56
-rw-r--r--src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx88
-rw-r--r--src/app/(main)/websites/[websiteId]/WebsiteNav.tsx180
-rw-r--r--src/app/(main)/websites/[websiteId]/WebsitePage.tsx22
-rw-r--r--src/app/(main)/websites/[websiteId]/WebsitePanels.tsx140
-rw-r--r--src/app/(main)/websites/[websiteId]/WebsiteTabs.tsx64
-rw-r--r--src/app/(main)/websites/[websiteId]/cohorts/CohortAddButton.tsx21
-rw-r--r--src/app/(main)/websites/[websiteId]/cohorts/CohortDeleteButton.tsx60
-rw-r--r--src/app/(main)/websites/[websiteId]/cohorts/CohortEditButton.tsx37
-rw-r--r--src/app/(main)/websites/[websiteId]/cohorts/CohortEditForm.tsx135
-rw-r--r--src/app/(main)/websites/[websiteId]/cohorts/CohortsDataTable.tsx24
-rw-r--r--src/app/(main)/websites/[websiteId]/cohorts/CohortsPage.tsx16
-rw-r--r--src/app/(main)/websites/[websiteId]/cohorts/CohortsTable.tsx41
-rw-r--r--src/app/(main)/websites/[websiteId]/cohorts/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx20
-rw-r--r--src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx171
-rw-r--r--src/app/(main)/websites/[websiteId]/compare/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/events/EventProperties.tsx127
-rw-r--r--src/app/(main)/websites/[websiteId]/events/EventsDataTable.tsx48
-rw-r--r--src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx40
-rw-r--r--src/app/(main)/websites/[websiteId]/events/EventsPage.tsx59
-rw-r--r--src/app/(main)/websites/[websiteId]/events/EventsTable.tsx107
-rw-r--r--src/app/(main)/websites/[websiteId]/events/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/layout.tsx21
-rw-r--r--src/app/(main)/websites/[websiteId]/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.tsx31
-rw-r--r--src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx17
-rw-r--r--src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx206
-rw-r--r--src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx58
-rw-r--r--src/app/(main)/websites/[websiteId]/realtime/RealtimePaths.tsx45
-rw-r--r--src/app/(main)/websites/[websiteId]/realtime/RealtimeReferrers.tsx45
-rw-r--r--src/app/(main)/websites/[websiteId]/realtime/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/segments/SegmentAddButton.tsx21
-rw-r--r--src/app/(main)/websites/[websiteId]/segments/SegmentDeleteButton.tsx60
-rw-r--r--src/app/(main)/websites/[websiteId]/segments/SegmentEditButton.tsx37
-rw-r--r--src/app/(main)/websites/[websiteId]/segments/SegmentEditForm.tsx86
-rw-r--r--src/app/(main)/websites/[websiteId]/segments/SegmentsDataTable.tsx24
-rw-r--r--src/app/(main)/websites/[websiteId]/segments/SegmentsPage.tsx16
-rw-r--r--src/app/(main)/websites/[websiteId]/segments/SegmentsTable.tsx38
-rw-r--r--src/app/(main)/websites/[websiteId]/segments/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx94
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionData.tsx32
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionInfo.tsx85
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionModal.tsx41
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx84
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx97
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionStats.tsx21
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx15
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx40
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx43
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx58
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/SettingsPage.tsx6
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteData.tsx104
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm.tsx40
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteEditForm.tsx55
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteResetForm.tsx37
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx28
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader.tsx22
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx93
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode.tsx40
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteTransferForm.tsx102
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/page.tsx12
101 files changed, 6025 insertions, 0 deletions
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution.tsx b/src/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution.tsx
new file mode 100644
index 0000000..264923a
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution.tsx
@@ -0,0 +1,128 @@
+import { Column, Grid } from '@umami/react-zen';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { Panel } from '@/components/common/Panel';
+import { SectionHeader } from '@/components/common/SectionHeader';
+import { useMessages, useResultQuery } from '@/components/hooks';
+import { ListTable } from '@/components/metrics/ListTable';
+import { MetricCard } from '@/components/metrics/MetricCard';
+import { MetricsBar } from '@/components/metrics/MetricsBar';
+import { percentFilter } from '@/lib/filters';
+import { formatLongNumber } from '@/lib/format';
+
+export interface AttributionProps {
+ websiteId: string;
+ startDate: Date;
+ endDate: Date;
+ model: string;
+ type: string;
+ step: string;
+ currency?: string;
+}
+
+export function Attribution({
+ websiteId,
+ startDate,
+ endDate,
+ model,
+ type,
+ step,
+ currency,
+}: AttributionProps) {
+ const { data, error, isLoading } = useResultQuery<any>('attribution', {
+ websiteId,
+ startDate,
+ endDate,
+ model,
+ type,
+ step,
+ });
+
+ const { formatMessage, labels } = useMessages();
+
+ const { pageviews, visitors, visits } = data?.total || {};
+
+ const metrics = data
+ ? [
+ {
+ value: visitors,
+ label: formatMessage(labels.visitors),
+ formatValue: formatLongNumber,
+ },
+ {
+ value: visits,
+ label: formatMessage(labels.visits),
+ formatValue: formatLongNumber,
+ },
+ {
+ value: pageviews,
+ label: formatMessage(labels.views),
+ formatValue: formatLongNumber,
+ },
+ ]
+ : [];
+
+ function AttributionTable({ data = [], title }: { data: any; title: string }) {
+ const attributionData = percentFilter(
+ data.map(({ name, value }) => ({
+ x: name,
+ y: Number(value),
+ })),
+ );
+
+ return (
+ <ListTable
+ title={title}
+ metric={formatMessage(currency ? labels.revenue : labels.visitors)}
+ currency={currency}
+ data={attributionData.map(({ x, y, z }: { x: string; y: number; z: number }) => ({
+ label: x,
+ count: y,
+ percent: z,
+ }))}
+ />
+ );
+ }
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ {data && (
+ <Column gap>
+ <MetricsBar>
+ {metrics?.map(({ label, value, formatValue }) => {
+ return (
+ <MetricCard key={label} value={value} label={label} formatValue={formatValue} />
+ );
+ })}
+ </MetricsBar>
+ <SectionHeader title={formatMessage(labels.sources)} />
+ <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap>
+ <Panel>
+ <AttributionTable data={data?.referrer} title={formatMessage(labels.referrer)} />
+ </Panel>
+ <Panel>
+ <AttributionTable data={data?.paidAds} title={formatMessage(labels.paidAds)} />
+ </Panel>
+ </Grid>
+ <SectionHeader title="UTM" />
+ <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap>
+ <Panel>
+ <AttributionTable data={data?.utm_source} title={formatMessage(labels.sources)} />
+ </Panel>
+ <Panel>
+ <AttributionTable data={data?.utm_medium} title={formatMessage(labels.medium)} />
+ </Panel>
+ <Panel>
+ <AttributionTable data={data?.utm_cmapaign} title={formatMessage(labels.campaigns)} />
+ </Panel>
+ <Panel>
+ <AttributionTable data={data?.utm_content} title={formatMessage(labels.content)} />
+ </Panel>
+ <Panel>
+ <AttributionTable data={data?.utm_term} title={formatMessage(labels.terms)} />
+ </Panel>
+ </Grid>
+ </Column>
+ )}
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx
new file mode 100644
index 0000000..48611c4
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx
@@ -0,0 +1,63 @@
+'use client';
+import { Column, Grid, ListItem, SearchField, Select } from '@umami/react-zen';
+import { useState } from 'react';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { useDateRange, useMessages } from '@/components/hooks';
+import { Attribution } from './Attribution';
+
+export function AttributionPage({ websiteId }: { websiteId: string }) {
+ const [model, setModel] = useState('first-click');
+ const [type, setType] = useState('path');
+ const [step, setStep] = useState('/');
+ const { formatMessage, labels } = useMessages();
+ const {
+ dateRange: { startDate, endDate },
+ } = useDateRange();
+
+ return (
+ <Column gap="6">
+ <WebsiteControls websiteId={websiteId} />
+ <Grid columns={{ xs: '1fr', md: '1fr 1fr 1fr' }} gap>
+ <Column>
+ <Select
+ label={formatMessage(labels.model)}
+ value={model}
+ defaultValue={model}
+ onChange={setModel}
+ >
+ <ListItem id="first-click">{formatMessage(labels.firstClick)}</ListItem>
+ <ListItem id="last-click">{formatMessage(labels.lastClick)}</ListItem>
+ </Select>
+ </Column>
+ <Column>
+ <Select
+ label={formatMessage(labels.type)}
+ value={type}
+ defaultValue={type}
+ onChange={setType}
+ >
+ <ListItem id="path">{formatMessage(labels.viewedPage)}</ListItem>
+ <ListItem id="event">{formatMessage(labels.triggeredEvent)}</ListItem>
+ </Select>
+ </Column>
+ <Column>
+ <SearchField
+ label={formatMessage(labels.conversionStep)}
+ value={step}
+ defaultValue={step}
+ onSearch={setStep}
+ delay={1000}
+ />
+ </Column>
+ </Grid>
+ <Attribution
+ websiteId={websiteId}
+ startDate={startDate}
+ endDate={endDate}
+ model={model}
+ type={type}
+ step={step}
+ />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/attribution/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/attribution/page.tsx
new file mode 100644
index 0000000..1368d4b
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/attribution/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { AttributionPage } from './AttributionPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <AttributionPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Attribution',
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx
new file mode 100644
index 0000000..4532d97
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx
@@ -0,0 +1,91 @@
+import { Column, DataColumn, DataTable, Text } from '@umami/react-zen';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useFields, useFormat, useMessages, useResultQuery } from '@/components/hooks';
+import { formatShortTime } from '@/lib/format';
+
+export interface BreakdownProps {
+ websiteId: string;
+ startDate: Date;
+ endDate: Date;
+ selectedFields: string[];
+}
+
+export function Breakdown({ websiteId, selectedFields = [], startDate, endDate }: BreakdownProps) {
+ const { formatMessage, labels } = useMessages();
+ const { formatValue } = useFormat();
+ const { fields } = useFields();
+ const { data, error, isLoading } = useResultQuery<any>(
+ 'breakdown',
+ {
+ websiteId,
+ startDate,
+ endDate,
+ fields: selectedFields,
+ },
+ { enabled: !!selectedFields.length },
+ );
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ <Column overflow="auto" minHeight="0" height="100%">
+ <DataTable data={data} style={{ tableLayout: 'fixed' }}>
+ {selectedFields.map(field => {
+ return (
+ <DataColumn
+ key={field}
+ id={field}
+ label={fields.find(f => f.name === field)?.label}
+ width="minmax(120px, 1fr)"
+ >
+ {row => {
+ const value = formatValue(row[field], field);
+ return (
+ <Text truncate title={value}>
+ {value}
+ </Text>
+ );
+ }}
+ </DataColumn>
+ );
+ })}
+ <DataColumn
+ id="visitors"
+ label={formatMessage(labels.visitors)}
+ align="end"
+ width="120px"
+ >
+ {row => row?.visitors?.toLocaleString()}
+ </DataColumn>
+ <DataColumn id="visits" label={formatMessage(labels.visits)} align="end" width="120px">
+ {row => row?.visits?.toLocaleString()}
+ </DataColumn>
+ <DataColumn id="views" label={formatMessage(labels.views)} align="end" width="120px">
+ {row => row?.views?.toLocaleString()}
+ </DataColumn>
+ <DataColumn
+ id="bounceRate"
+ label={formatMessage(labels.bounceRate)}
+ align="end"
+ width="120px"
+ >
+ {row => {
+ const n = (Math.min(row?.visits, row?.bounces) / row?.visits) * 100;
+ return `${Math.round(+n)}%`;
+ }}
+ </DataColumn>
+ <DataColumn
+ id="visitDuration"
+ label={formatMessage(labels.visitDuration)}
+ align="end"
+ width="120px"
+ >
+ {row => {
+ const n = row?.totaltime / row?.visits;
+ return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`;
+ }}
+ </DataColumn>
+ </DataTable>
+ </Column>
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx
new file mode 100644
index 0000000..fdead9f
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx
@@ -0,0 +1,51 @@
+'use client';
+import { Column, Row } from '@umami/react-zen';
+import { useState } from 'react';
+import { FieldSelectForm } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { Panel } from '@/components/common/Panel';
+import { useDateRange, useMessages } from '@/components/hooks';
+import { ListCheck } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { Breakdown } from './Breakdown';
+
+export function BreakdownPage({ websiteId }: { websiteId: string }) {
+ const {
+ dateRange: { startDate, endDate },
+ } = useDateRange();
+ const [fields, setFields] = useState(['path']);
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} />
+ <Row alignItems="center" justifyContent="flex-start">
+ <FieldsButton value={fields} onChange={setFields} />
+ </Row>
+ <Panel height="900px" overflow="auto" allowFullscreen>
+ <Breakdown
+ websiteId={websiteId}
+ startDate={startDate}
+ endDate={endDate}
+ selectedFields={fields}
+ />
+ </Panel>
+ </Column>
+ );
+}
+
+const FieldsButton = ({ value, onChange }) => {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogButton
+ icon={<ListCheck />}
+ label={formatMessage(labels.fields)}
+ width="400px"
+ minHeight="300px"
+ variant="outline"
+ >
+ {({ close }) => {
+ return <FieldSelectForm selectedFields={value} onChange={onChange} onClose={close} />;
+ }}
+ </DialogButton>
+ );
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx
new file mode 100644
index 0000000..28e3368
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx
@@ -0,0 +1,46 @@
+import { Button, Column, Grid, List, ListItem } from '@umami/react-zen';
+import { useState } from 'react';
+import { useFields, useMessages } from '@/components/hooks';
+
+export function FieldSelectForm({
+ selectedFields = [],
+ onChange,
+ onClose,
+}: {
+ selectedFields?: string[];
+ onChange: (values: string[]) => void;
+ onClose?: () => void;
+}) {
+ const [selected, setSelected] = useState(selectedFields);
+ const { formatMessage, labels } = useMessages();
+ const { fields } = useFields();
+
+ const handleChange = (value: string[]) => {
+ setSelected(value);
+ };
+
+ const handleApply = () => {
+ onChange?.(selected);
+ onClose();
+ };
+
+ return (
+ <Column gap="6">
+ <List value={selected} onChange={handleChange} selectionMode="multiple">
+ {fields.map(({ name, label }) => {
+ return (
+ <ListItem key={name} id={name}>
+ {label}
+ </ListItem>
+ );
+ })}
+ </List>
+ <Grid columns="1fr 1fr" gap>
+ <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
+ <Button onPress={handleApply} variant="primary">
+ {formatMessage(labels.apply)}
+ </Button>
+ </Grid>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/page.tsx
new file mode 100644
index 0000000..841d863
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { BreakdownPage } from './BreakdownPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <BreakdownPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Insights',
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx
new file mode 100644
index 0000000..e336a3d
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx
@@ -0,0 +1,134 @@
+import { Box, Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useMessages, useResultQuery } from '@/components/hooks';
+import { File, User } from '@/components/icons';
+import { ReportEditButton } from '@/components/input/ReportEditButton';
+import { ChangeLabel } from '@/components/metrics/ChangeLabel';
+import { Lightning } from '@/components/svg';
+import { formatLongNumber } from '@/lib/format';
+import { FunnelEditForm } from './FunnelEditForm';
+
+type FunnelResult = {
+ type: string;
+ value: string;
+ visitors: number;
+ previous: number;
+ dropped: number;
+ dropoff: number;
+ remaining: number;
+};
+
+export function Funnel({ id, name, type, parameters, websiteId }) {
+ const { formatMessage, labels } = useMessages();
+ const { data, error, isLoading } = useResultQuery(type, {
+ websiteId,
+ ...parameters,
+ });
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ <Grid gap>
+ <Grid columns="1fr auto" gap>
+ <Column gap>
+ <Row>
+ <Text size="4" weight="bold">
+ {name}
+ </Text>
+ </Row>
+ </Column>
+ <Column>
+ <ReportEditButton id={id} name={name} type={type}>
+ {({ close }) => {
+ return (
+ <Dialog
+ title={formatMessage(labels.funnel)}
+ variant="modal"
+ style={{ minHeight: 300, minWidth: 400 }}
+ >
+ <FunnelEditForm id={id} websiteId={websiteId} onClose={close} />
+ </Dialog>
+ );
+ }}
+ </ReportEditButton>
+ </Column>
+ </Grid>
+ {data?.map(
+ (
+ { type, value, visitors, previous, dropped, dropoff, remaining }: FunnelResult,
+ index: number,
+ ) => {
+ const isPage = type === 'path';
+ return (
+ <Grid key={index} columns="auto 1fr" gap="6">
+ <Column alignItems="center" position="relative">
+ <Row
+ borderRadius="full"
+ backgroundColor="3"
+ width="40px"
+ height="40px"
+ justifyContent="center"
+ alignItems="center"
+ style={{ zIndex: 1 }}
+ >
+ <Text weight="bold" size="3">
+ {index + 1}
+ </Text>
+ </Row>
+ {index > 0 && (
+ <Box
+ position="absolute"
+ backgroundColor="3"
+ width="2px"
+ height="120px"
+ top="-100%"
+ />
+ )}
+ </Column>
+ <Column gap>
+ <Row alignItems="center" justifyContent="space-between" gap>
+ <Text color="muted">
+ {formatMessage(isPage ? labels.viewedPage : labels.triggeredEvent)}
+ </Text>
+ <Text color="muted">{formatMessage(labels.conversionRate)}</Text>
+ </Row>
+ <Row alignItems="center" justifyContent="space-between" gap>
+ <Row alignItems="center" gap>
+ <Icon>{type === 'path' ? <File /> : <Lightning />}</Icon>
+ <Text>{value}</Text>
+ </Row>
+ <Row alignItems="center" gap>
+ {index > 0 && (
+ <ChangeLabel value={-dropped} title={`${-Math.round(dropoff * 100)}%`}>
+ {formatLongNumber(dropped)}
+ </ChangeLabel>
+ )}
+ <Icon>
+ <User />
+ </Icon>
+ <Text title={visitors.toString()} transform="lowercase">
+ {`${formatLongNumber(visitors)} ${formatMessage(labels.visitors)}`}
+ </Text>
+ </Row>
+ </Row>
+ <Row alignItems="center" gap="6">
+ <ProgressBar
+ value={visitors || 0}
+ minValue={0}
+ maxValue={previous || 1}
+ style={{ width: '100%' }}
+ />
+ <Row minWidth="90px" justifyContent="end">
+ <Text weight="bold" size="7">
+ {Math.round(remaining * 100)}%
+ </Text>
+ </Row>
+ </Row>
+ </Column>
+ </Grid>
+ );
+ },
+ )}
+ </Grid>
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelAddButton.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelAddButton.tsx
new file mode 100644
index 0000000..29b5480
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelAddButton.tsx
@@ -0,0 +1,28 @@
+import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+import { FunnelEditForm } from './FunnelEditForm';
+
+export function FunnelAddButton({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogTrigger>
+ <Button variant="primary">
+ <Icon>
+ <Plus />
+ </Icon>
+ <Text>{formatMessage(labels.funnel)}</Text>
+ </Button>
+ <Modal>
+ <Dialog
+ variant="modal"
+ title={formatMessage(labels.funnel)}
+ style={{ minHeight: 375, minWidth: 600 }}
+ >
+ {({ close }) => <FunnelEditForm websiteId={websiteId} onClose={close} />}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx
new file mode 100644
index 0000000..5d950ea
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx
@@ -0,0 +1,141 @@
+import {
+ Button,
+ Column,
+ Form,
+ FormButtons,
+ FormField,
+ FormFieldArray,
+ FormSubmitButton,
+ Grid,
+ Icon,
+ Loading,
+ Row,
+ Text,
+ TextField,
+} from '@umami/react-zen';
+import { useMessages, useReportQuery, useUpdateQuery } from '@/components/hooks';
+import { Plus, X } from '@/components/icons';
+import { ActionSelect } from '@/components/input/ActionSelect';
+import { LookupField } from '@/components/input/LookupField';
+
+const FUNNEL_STEPS_MAX = 8;
+
+export function FunnelEditForm({
+ id,
+ websiteId,
+ onSave,
+ onClose,
+}: {
+ id?: string;
+ websiteId: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { data } = useReportQuery(id);
+ const { mutateAsync, error, isPending, touch } = useUpdateQuery(`/reports${id ? `/${id}` : ''}`);
+
+ const handleSubmit = async ({ name, ...parameters }) => {
+ await mutateAsync(
+ { ...data, id, name, type: 'funnel', websiteId, parameters },
+ {
+ onSuccess: async () => {
+ touch('reports:funnel');
+ touch(`report:${id}`);
+ onSave?.();
+ onClose?.();
+ },
+ },
+ );
+ };
+
+ if (id && !data) {
+ return <Loading placement="absolute" />;
+ }
+
+ const defaultValues = {
+ name: data?.name || '',
+ window: data?.parameters?.window || 60,
+ steps: data?.parameters?.steps || [{ type: 'path', value: '' }],
+ };
+
+ return (
+ <Form onSubmit={handleSubmit} error={error?.message} defaultValues={defaultValues}>
+ <FormField
+ name="name"
+ label={formatMessage(labels.name)}
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField autoFocus />
+ </FormField>
+ <FormField
+ name="window"
+ label={formatMessage(labels.window)}
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField />
+ </FormField>
+ <FormFieldArray
+ name="steps"
+ label={formatMessage(labels.steps)}
+ rules={{
+ validate: value => value.length > 1 || 'At least two steps are required',
+ }}
+ >
+ {({ fields, append, remove }) => {
+ return (
+ <Grid gap>
+ {fields.map(({ id }: { id: string }, index: number) => {
+ return (
+ <Grid key={id} columns="260px 1fr auto" gap>
+ <Column>
+ <FormField
+ name={`steps.${index}.type`}
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <ActionSelect />
+ </FormField>
+ </Column>
+ <Column>
+ <FormField
+ name={`steps.${index}.value`}
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ {({ field, context }) => {
+ const type = context.watch(`steps.${index}.type`);
+ return <LookupField websiteId={websiteId} type={type} {...field} />;
+ }}
+ </FormField>
+ </Column>
+ <Button onPress={() => remove(index)}>
+ <Icon size="sm">
+ <X />
+ </Icon>
+ </Button>
+ </Grid>
+ );
+ })}
+ <Row>
+ <Button
+ onPress={() => append({ type: 'path', value: '' })}
+ isDisabled={fields.length >= FUNNEL_STEPS_MAX}
+ >
+ <Icon>
+ <Plus />
+ </Icon>
+ <Text>{formatMessage(labels.add)}</Text>
+ </Button>
+ </Row>
+ </Grid>
+ );
+ }}
+ </FormFieldArray>
+ <FormButtons>
+ <Button onPress={onClose} isDisabled={isPending}>
+ {formatMessage(labels.cancel)}
+ </Button>
+ <FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton>
+ </FormButtons>
+ </Form>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx
new file mode 100644
index 0000000..57bce52
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx
@@ -0,0 +1,36 @@
+'use client';
+import { Column, Grid } from '@umami/react-zen';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { Panel } from '@/components/common/Panel';
+import { SectionHeader } from '@/components/common/SectionHeader';
+import { useDateRange, useReportsQuery } from '@/components/hooks';
+import { Funnel } from './Funnel';
+import { FunnelAddButton } from './FunnelAddButton';
+
+export function FunnelsPage({ websiteId }: { websiteId: string }) {
+ const { data, isLoading, error } = useReportsQuery({ websiteId, type: 'funnel' });
+ const {
+ dateRange: { startDate, endDate },
+ } = useDateRange();
+
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} />
+ <SectionHeader>
+ <FunnelAddButton websiteId={websiteId} />
+ </SectionHeader>
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ {data && (
+ <Grid gap>
+ {data.data?.map((report: any) => (
+ <Panel key={report.id}>
+ <Funnel {...report} startDate={startDate} endDate={endDate} />
+ </Panel>
+ ))}
+ </Grid>
+ )}
+ </LoadingPanel>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/page.tsx
new file mode 100644
index 0000000..2fdcf3b
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { FunnelsPage } from './FunnelsPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <FunnelsPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Funnels',
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx
new file mode 100644
index 0000000..b6c4a11
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx
@@ -0,0 +1,99 @@
+import { Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useMessages, useResultQuery } from '@/components/hooks';
+import { File, User } from '@/components/icons';
+import { ReportEditButton } from '@/components/input/ReportEditButton';
+import { Lightning } from '@/components/svg';
+import { formatLongNumber } from '@/lib/format';
+import { GoalEditForm } from './GoalEditForm';
+
+export interface GoalProps {
+ id: string;
+ name: string;
+ type: string;
+ parameters: {
+ name: string;
+ type: string;
+ value: string;
+ };
+ websiteId: string;
+ startDate: Date;
+ endDate: Date;
+}
+
+export type GoalData = { num: number; total: number };
+
+export function Goal({ id, name, type, parameters, websiteId, startDate, endDate }: GoalProps) {
+ const { formatMessage, labels } = useMessages();
+ const { data, error, isLoading, isFetching } = useResultQuery<GoalData>(type, {
+ websiteId,
+ startDate,
+ endDate,
+ ...parameters,
+ });
+ const isPage = parameters?.type === 'path';
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
+ {data && (
+ <Grid gap>
+ <Grid columns="1fr auto" gap>
+ <Column gap>
+ <Row>
+ <Text size="4" weight="bold">
+ {name}
+ </Text>
+ </Row>
+ </Column>
+ <Column>
+ <ReportEditButton id={id} name={name} type={type}>
+ {({ close }) => {
+ return (
+ <Dialog
+ title={formatMessage(labels.goal)}
+ variant="modal"
+ style={{ minHeight: 300, minWidth: 400 }}
+ >
+ <GoalEditForm id={id} websiteId={websiteId} onClose={close} />
+ </Dialog>
+ );
+ }}
+ </ReportEditButton>
+ </Column>
+ </Grid>
+ <Row alignItems="center" justifyContent="space-between" gap>
+ <Text color="muted">
+ {formatMessage(isPage ? labels.viewedPage : labels.triggeredEvent)}
+ </Text>
+ <Text color="muted">{formatMessage(labels.conversionRate)}</Text>
+ </Row>
+ <Row alignItems="center" justifyContent="space-between" gap>
+ <Row alignItems="center" gap>
+ <Icon>{parameters.type === 'path' ? <File /> : <Lightning />}</Icon>
+ <Text>{parameters.value}</Text>
+ </Row>
+ <Row alignItems="center" gap>
+ <Icon>
+ <User />
+ </Icon>
+ <Text title={`${data?.num} / ${data?.total}`}>{`${formatLongNumber(
+ data?.num,
+ )} / ${formatLongNumber(data?.total)}`}</Text>
+ </Row>
+ </Row>
+ <Row alignItems="center" gap="6">
+ <ProgressBar
+ value={data?.num || 0}
+ minValue={0}
+ maxValue={data?.total || 1}
+ style={{ width: '100%' }}
+ />
+ <Text weight="bold" size="7">
+ {data?.total ? Math.round((+data?.num / +data?.total) * 100) : '0'}%
+ </Text>
+ </Row>
+ </Grid>
+ )}
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalAddButton.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalAddButton.tsx
new file mode 100644
index 0000000..c85b79c
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalAddButton.tsx
@@ -0,0 +1,28 @@
+import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+import { GoalEditForm } from './GoalEditForm';
+
+export function GoalAddButton({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogTrigger>
+ <Button variant="primary">
+ <Icon>
+ <Plus />
+ </Icon>
+ <Text>{formatMessage(labels.goal)}</Text>
+ </Button>
+ <Modal>
+ <Dialog
+ aria-label="add goal"
+ title={formatMessage(labels.goal)}
+ style={{ minWidth: 400, minHeight: 300 }}
+ >
+ {({ close }) => <GoalEditForm websiteId={websiteId} onClose={close} />}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalEditForm.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalEditForm.tsx
new file mode 100644
index 0000000..7f68047
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalEditForm.tsx
@@ -0,0 +1,104 @@
+import {
+ Button,
+ Column,
+ Form,
+ FormButtons,
+ FormField,
+ FormSubmitButton,
+ Grid,
+ Label,
+ Loading,
+ TextField,
+} from '@umami/react-zen';
+import { useMessages, useReportQuery, useUpdateQuery } from '@/components/hooks';
+import { ActionSelect } from '@/components/input/ActionSelect';
+import { LookupField } from '@/components/input/LookupField';
+
+export function GoalEditForm({
+ id,
+ websiteId,
+ onSave,
+ onClose,
+}: {
+ id?: string;
+ websiteId: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { data } = useReportQuery(id);
+ const { mutateAsync, error, isPending, touch } = useUpdateQuery(`/reports${id ? `/${id}` : ''}`);
+
+ const handleSubmit = async (formData: Record<string, any>) => {
+ await mutateAsync(
+ { ...formData, type: 'goal', websiteId },
+ {
+ onSuccess: async () => {
+ if (id) touch(`report:${id}`);
+ touch('reports:goal');
+ onSave?.();
+ onClose?.();
+ },
+ },
+ );
+ };
+
+ if (id && !data) {
+ return <Loading placement="absolute" />;
+ }
+
+ const defaultValues = {
+ name: '',
+ parameters: { type: 'path', value: '' },
+ };
+
+ return (
+ <Form onSubmit={handleSubmit} error={error?.message} defaultValues={data || defaultValues}>
+ {({ watch }) => {
+ const type = watch('parameters.type');
+
+ return (
+ <>
+ <FormField
+ name="name"
+ label={formatMessage(labels.name)}
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField autoFocus />
+ </FormField>
+ <Column>
+ <Label>{formatMessage(labels.action)}</Label>
+ <Grid columns="260px 1fr" gap>
+ <Column>
+ <FormField
+ name="parameters.type"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <ActionSelect />
+ </FormField>
+ </Column>
+ <Column>
+ <FormField
+ name="parameters.value"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ {({ field }) => {
+ return <LookupField websiteId={websiteId} type={type} {...field} />;
+ }}
+ </FormField>
+ </Column>
+ </Grid>
+ </Column>
+
+ <FormButtons>
+ <Button onPress={onClose} isDisabled={isPending}>
+ {formatMessage(labels.cancel)}
+ </Button>
+ <FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton>
+ </FormButtons>
+ </>
+ );
+ }}
+ </Form>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx
new file mode 100644
index 0000000..ff7b49f
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx
@@ -0,0 +1,36 @@
+'use client';
+import { Column, Grid } from '@umami/react-zen';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { Panel } from '@/components/common/Panel';
+import { SectionHeader } from '@/components/common/SectionHeader';
+import { useDateRange, useReportsQuery } from '@/components/hooks';
+import { Goal } from './Goal';
+import { GoalAddButton } from './GoalAddButton';
+
+export function GoalsPage({ websiteId }: { websiteId: string }) {
+ const { data, isLoading, error } = useReportsQuery({ websiteId, type: 'goal' });
+ const {
+ dateRange: { startDate, endDate },
+ } = useDateRange();
+
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} />
+ <SectionHeader>
+ <GoalAddButton websiteId={websiteId} />
+ </SectionHeader>
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ {data && (
+ <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap>
+ {data.data.map((report: any) => (
+ <Panel key={report.id}>
+ <Goal {...report} startDate={startDate} endDate={endDate} />
+ </Panel>
+ ))}
+ </Grid>
+ )}
+ </LoadingPanel>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/page.tsx
new file mode 100644
index 0000000..b1ab691
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { GoalsPage } from './GoalsPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <GoalsPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Goals',
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css
new file mode 100644
index 0000000..63643f1
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css
@@ -0,0 +1,267 @@
+.container {
+ width: 100%;
+ height: 100%;
+ position: relative;
+
+ --journey-line-color: var(--base-color-6);
+ --journey-active-color: var(--primary-color);
+ --journey-faded-color: var(--base-color-3);
+}
+
+.view {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ overflow: auto;
+ gap: 100px;
+ padding-right: 20px;
+}
+
+.header {
+ margin-bottom: 20px;
+}
+
+.stats {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 10px;
+ width: 100%;
+}
+
+.visitors {
+ font-weight: 600;
+ font-size: 16px;
+ text-transform: lowercase;
+}
+
+.dropoff {
+ font-weight: 600;
+ color: var(--font-color-muted);
+ background: var(--base-color-2);
+ padding: 4px 8px;
+ border-radius: 5px;
+}
+
+.num {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 100%;
+ width: 50px;
+ height: 50px;
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--base-color-1);
+ background: var(--base-color-12);
+ z-index: 1;
+ margin: 0 auto 20px;
+}
+
+.column {
+ display: flex;
+ flex-direction: column;
+}
+
+.nodes {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.wrapper {
+ padding-bottom: 10px;
+}
+
+.node {
+ position: relative;
+ cursor: pointer;
+ padding: 10px 20px;
+ background: var(--base-color-3);
+ border-radius: 5px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 300px;
+ max-width: 300px;
+ height: 60px;
+ max-height: 60px;
+}
+
+.node:hover:not(.selected) {
+ background: var(--base-color-4);
+}
+
+.node.selected {
+ color: var(--base-color-1);
+ background: var(--base-color-12);
+}
+
+.node.active {
+ color: var(--primary-font-color);
+ background: var(--primary-color);
+}
+
+.node.selected .count {
+ color: var(--base-color-1);
+ background: var(--base-color-12);
+}
+
+.node.selected.active .count {
+ color: var(--primary-font-color);
+ background: var(--primary-color);
+}
+
+.name {
+ max-width: 200px;
+}
+
+.line {
+ position: absolute;
+ bottom: 0;
+ left: -100px;
+ width: 100px;
+ pointer-events: none;
+}
+
+.line.up {
+ bottom: 0;
+}
+
+.line.down {
+ top: 0;
+}
+
+.segment {
+ position: absolute;
+}
+
+.start {
+ left: 0;
+ width: 50px;
+ height: 30px;
+ border: 0;
+}
+
+.mid {
+ top: 60px;
+ width: 50px;
+ border-right: 3px solid var(--journey-line-color);
+}
+
+.end {
+ width: 50px;
+ height: 30px;
+ border: 0;
+}
+
+.up .start {
+ top: 30px;
+ border-top-right-radius: 100%;
+ border-top: 3px solid var(--journey-line-color);
+ border-right: 3px solid var(--journey-line-color);
+}
+
+.up .end {
+ width: 52px;
+ bottom: 27px;
+ right: 0;
+ border-bottom-left-radius: 100%;
+ border-bottom: 3px solid var(--journey-line-color);
+ border-left: 3px solid var(--journey-line-color);
+}
+
+.down .start {
+ bottom: 27px;
+ border-bottom-right-radius: 100%;
+ border-bottom: 3px solid var(--journey-line-color);
+ border-right: 3px solid var(--journey-line-color);
+}
+
+.down .end {
+ width: 52px;
+ top: 30px;
+ right: 0;
+ border-top-left-radius: 100%;
+ border-top: 3px solid var(--journey-line-color);
+ border-left: 3px solid var(--journey-line-color);
+}
+
+.flat .start {
+ left: 0;
+ top: 30px;
+ border-top: 3px solid var(--journey-line-color);
+}
+
+.flat .end {
+ right: 0;
+ top: 30px;
+ border-top: 3px solid var(--journey-line-color);
+}
+
+.start:before,
+.end:before {
+ content: "";
+ position: absolute;
+ border-radius: 100%;
+ border: 3px solid var(--journey-line-color);
+ background: var(--base-color-1);
+ width: 14px;
+ height: 14px;
+}
+
+.line:not(.active) .start:before,
+.line:not(.active) .end:before {
+ display: none;
+}
+
+.up .start:before {
+ left: -8px;
+ top: -8px;
+}
+
+.up .end:before {
+ right: -8px;
+ bottom: -8px;
+}
+
+.down .start:before {
+ left: -8px;
+ bottom: -8px;
+}
+
+.down .end:before {
+ right: -8px;
+ top: -8px;
+}
+
+.flat .start:before {
+ left: -8px;
+ top: -8px;
+}
+
+.flat .end:before {
+ right: -8px;
+ top: -8px;
+}
+
+.line.active .segment,
+.line.active .segment:before {
+ border-color: var(--journey-active-color);
+ z-index: 1;
+}
+
+.column.active .line:not(.active) .segment {
+ border-color: var(--journey-faded-color);
+}
+
+.column.active .line:not(.active) .segment:before {
+ display: none;
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx
new file mode 100644
index 0000000..3327a42
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx
@@ -0,0 +1,294 @@
+import { Column, Focusable, Icon, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen';
+import classNames from 'classnames';
+import { useMemo, useState } from 'react';
+import { firstBy } from 'thenby';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useEscapeKey, useMessages, useResultQuery } from '@/components/hooks';
+import { File } from '@/components/icons';
+import { Lightning } from '@/components/svg';
+import { objectToArray } from '@/lib/data';
+import { formatLongNumber } from '@/lib/format';
+import styles from './Journey.module.css';
+
+const NODE_HEIGHT = 60;
+const NODE_GAP = 10;
+const LINE_WIDTH = 3;
+
+export interface JourneyProps {
+ websiteId: string;
+ startDate: Date;
+ endDate: Date;
+ steps: number;
+ startStep?: string;
+ endStep?: string;
+}
+
+export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps) {
+ const [selectedNode, setSelectedNode] = useState(null);
+ const [activeNode, setActiveNode] = useState(null);
+ const { formatMessage, labels } = useMessages();
+ const { data, error, isLoading } = useResultQuery<any>('journey', {
+ websiteId,
+ steps,
+ startStep,
+ endStep,
+ });
+
+ useEscapeKey(() => setSelectedNode(null));
+
+ const columns = useMemo(() => {
+ if (!data) {
+ return [];
+ }
+
+ const selectedPaths = selectedNode?.paths ?? [];
+ const activePaths = activeNode?.paths ?? [];
+ const columns = [];
+
+ for (let columnIndex = 0; columnIndex < +steps; columnIndex++) {
+ const nodes = {};
+
+ data.forEach(({ items, count }: any, nodeIndex: any) => {
+ const name = items[columnIndex];
+
+ if (name) {
+ const selected = !!selectedPaths.find(({ items }) => items[columnIndex] === name);
+ const active = selected && !!activePaths.find(({ items }) => items[columnIndex] === name);
+
+ if (!nodes[name]) {
+ const paths = data.filter(({ items }) => items[columnIndex] === name);
+
+ nodes[name] = {
+ name,
+ count,
+ totalCount: count,
+ nodeIndex,
+ columnIndex,
+ selected,
+ active,
+ paths,
+ pathMap: paths.map(({ items, count }) => ({
+ [`${columnIndex}:${items.join(':')}`]: count,
+ })),
+ };
+ } else {
+ nodes[name].totalCount += count;
+ }
+ }
+ });
+
+ columns.push({
+ nodes: objectToArray(nodes).sort(firstBy('total', -1)),
+ });
+ }
+
+ columns.forEach((column, columnIndex) => {
+ const nodes = column.nodes.map(
+ (
+ currentNode: { totalCount: number; name: string; selected: boolean },
+ currentNodeIndex: any,
+ ) => {
+ const previousNodes = columns[columnIndex - 1]?.nodes;
+ let selectedCount = previousNodes ? 0 : currentNode.totalCount;
+ let activeCount = selectedCount;
+
+ const lines =
+ previousNodes?.reduce((arr: any[][], previousNode: any, previousNodeIndex: number) => {
+ const fromCount = selectedNode?.paths.reduce((sum, path) => {
+ if (
+ previousNode.name === path.items[columnIndex - 1] &&
+ currentNode.name === path.items[columnIndex]
+ ) {
+ sum += path.count;
+ }
+ return sum;
+ }, 0);
+
+ if (currentNode.selected && previousNode.selected && fromCount) {
+ arr.push([previousNodeIndex, currentNodeIndex]);
+ selectedCount += fromCount;
+
+ if (previousNode.active) {
+ activeCount += fromCount;
+ }
+ }
+
+ return arr;
+ }, []) || [];
+
+ return { ...currentNode, selectedCount, activeCount, lines };
+ },
+ );
+
+ const visitorCount = nodes.reduce(
+ (sum: number, { selected, selectedCount, active, activeCount, totalCount }) => {
+ if (!selectedNode) {
+ sum += totalCount;
+ } else if (!activeNode && selectedNode && selected) {
+ sum += selectedCount;
+ } else if (activeNode && active) {
+ sum += activeCount;
+ }
+ return sum;
+ },
+ 0,
+ );
+
+ const previousTotal = columns[columnIndex - 1]?.visitorCount ?? 0;
+ const dropOff =
+ previousTotal > 0 ? ((visitorCount - previousTotal) / previousTotal) * 100 : 0;
+
+ Object.assign(column, { nodes, visitorCount, dropOff });
+ });
+
+ return columns;
+ }, [data, selectedNode, activeNode]);
+
+ const handleClick = (name: string, columnIndex: number, paths: any[]) => {
+ if (name !== selectedNode?.name || columnIndex !== selectedNode?.columnIndex) {
+ setSelectedNode({ name, columnIndex, paths });
+ } else {
+ setSelectedNode(null);
+ }
+ setActiveNode(null);
+ };
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error} height="100%">
+ <div className={styles.container}>
+ <div className={styles.view}>
+ {columns.map(({ visitorCount, nodes }, columnIndex) => {
+ return (
+ <div
+ key={columnIndex}
+ className={classNames(styles.column, {
+ [styles.selected]: selectedNode,
+ [styles.active]: activeNode,
+ })}
+ >
+ <div className={styles.header}>
+ <div className={styles.num}>{columnIndex + 1}</div>
+ <div className={styles.stats}>
+ <div className={styles.visitors} title={visitorCount}>
+ {formatLongNumber(visitorCount)} {formatMessage(labels.visitors)}
+ </div>
+ </div>
+ </div>
+ <div className={styles.nodes}>
+ {nodes.map(
+ ({
+ name,
+ totalCount,
+ selected,
+ active,
+ paths,
+ activeCount,
+ selectedCount,
+ lines,
+ }) => {
+ const nodeCount = selected
+ ? active
+ ? activeCount
+ : selectedCount
+ : totalCount;
+
+ const remaining =
+ columnIndex > 0
+ ? Math.round((nodeCount / columns[columnIndex - 1]?.visitorCount) * 100)
+ : 0;
+
+ const dropped = 100 - remaining;
+
+ return (
+ <div
+ key={name}
+ className={styles.wrapper}
+ onMouseEnter={() =>
+ selected && setActiveNode({ name, columnIndex, paths })
+ }
+ onMouseLeave={() => selected && setActiveNode(null)}
+ >
+ <div
+ className={classNames(styles.node, {
+ [styles.selected]: selected,
+ [styles.active]: active,
+ })}
+ onClick={() => handleClick(name, columnIndex, paths)}
+ >
+ <Row alignItems="center" className={styles.name} title={name} gap>
+ <Icon>{name.startsWith('/') ? <File /> : <Lightning />}</Icon>
+ <Text truncate>{name}</Text>
+ </Row>
+ <div className={styles.count} title={nodeCount}>
+ <TooltipTrigger
+ delay={0}
+ isDisabled={columnIndex === 0 || (selectedNode && !selected)}
+ >
+ <Focusable>
+ <div>{formatLongNumber(nodeCount)}</div>
+ </Focusable>
+ <Tooltip placement="top" offset={20} showArrow>
+ <Text transform="lowercase" color="ruby">
+ {`${dropped}% ${formatMessage(labels.dropoff)}`}
+ </Text>
+ <Column>
+ <Text transform="lowercase">
+ {`${remaining}% ${formatMessage(labels.conversion)}`}
+ </Text>
+ </Column>
+ </Tooltip>
+ </TooltipTrigger>
+ </div>
+ {columnIndex < columns.length &&
+ lines.map(([fromIndex, nodeIndex], i) => {
+ const height =
+ (Math.abs(nodeIndex - fromIndex) + 1) * (NODE_HEIGHT + NODE_GAP) -
+ NODE_GAP;
+ const midHeight =
+ (Math.abs(nodeIndex - fromIndex) - 1) * (NODE_HEIGHT + NODE_GAP) +
+ NODE_GAP +
+ LINE_WIDTH;
+ const nodeName = columns[columnIndex - 1]?.nodes[fromIndex].name;
+
+ return (
+ <div
+ key={`${fromIndex}${nodeIndex}${i}`}
+ className={classNames(styles.line, {
+ [styles.active]:
+ active &&
+ activeNode?.paths.find(
+ (path: { items: any[] }) =>
+ path.items[columnIndex] === name &&
+ path.items[columnIndex - 1] === nodeName,
+ ),
+ [styles.up]: fromIndex < nodeIndex,
+ [styles.down]: fromIndex > nodeIndex,
+ [styles.flat]: fromIndex === nodeIndex,
+ })}
+ style={{ height }}
+ >
+ <div className={classNames(styles.segment, styles.start)} />
+ <div
+ className={classNames(styles.segment, styles.mid)}
+ style={{
+ height: midHeight,
+ }}
+ />
+ <div className={classNames(styles.segment, styles.end)} />
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ );
+ },
+ )}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx
new file mode 100644
index 0000000..14b8341
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx
@@ -0,0 +1,67 @@
+'use client';
+import { Column, Grid, ListItem, SearchField, Select } from '@umami/react-zen';
+import { useState } from 'react';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { Panel } from '@/components/common/Panel';
+import { useDateRange, useMessages } from '@/components/hooks';
+import { Journey } from './Journey';
+
+const JOURNEY_STEPS = [2, 3, 4, 5, 6, 7];
+const DEFAULT_STEP = 3;
+
+export function JourneysPage({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+ const {
+ dateRange: { startDate, endDate },
+ } = useDateRange();
+ const [steps, setSteps] = useState(DEFAULT_STEP);
+ const [startStep, setStartStep] = useState('');
+ const [endStep, setEndStep] = useState('');
+
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} />
+ <Grid columns="repeat(3, 1fr)" gap>
+ <Select
+ items={JOURNEY_STEPS}
+ label={formatMessage(labels.steps)}
+ value={steps}
+ defaultValue={steps}
+ onChange={setSteps}
+ >
+ {JOURNEY_STEPS.map(step => (
+ <ListItem key={step} id={step}>
+ {step}
+ </ListItem>
+ ))}
+ </Select>
+ <Column>
+ <SearchField
+ label={formatMessage(labels.startStep)}
+ value={startStep}
+ onSearch={setStartStep}
+ delay={1000}
+ />
+ </Column>
+ <Column>
+ <SearchField
+ label={formatMessage(labels.endStep)}
+ value={endStep}
+ onSearch={setEndStep}
+ delay={1000}
+ />
+ </Column>
+ </Grid>
+ <Panel height="900px" allowFullscreen>
+ <Journey
+ websiteId={websiteId}
+ startDate={startDate}
+ endDate={endDate}
+ steps={steps}
+ startStep={startStep}
+ endStep={endStep}
+ />
+ </Panel>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx
new file mode 100644
index 0000000..f6062a6
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { JourneysPage } from './JourneysPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <JourneysPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Journeys',
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/retention/Retention.tsx b/src/app/(main)/websites/[websiteId]/(reports)/retention/Retention.tsx
new file mode 100644
index 0000000..fdd8a14
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/retention/Retention.tsx
@@ -0,0 +1,140 @@
+import { Column, Grid, Icon, Row, Text } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { Panel } from '@/components/common/Panel';
+import { useLocale, useMessages, useResultQuery } from '@/components/hooks';
+import { Users } from '@/components/icons';
+import { formatDate } from '@/lib/date';
+import { formatLongNumber } from '@/lib/format';
+
+const DAYS = [1, 2, 3, 4, 5, 6, 7, 14, 21, 28];
+
+export interface RetentionProps {
+ websiteId: string;
+ startDate: Date;
+ endDate: Date;
+ days?: number[];
+}
+
+export function Retention({ websiteId, days = DAYS, startDate, endDate }: RetentionProps) {
+ const { formatMessage, labels } = useMessages();
+ const { locale } = useLocale();
+ const { data, error, isLoading } = useResultQuery('retention', {
+ websiteId,
+ startDate,
+ endDate,
+ });
+
+ const rows =
+ data?.reduce((arr: any[], row: { date: any; visitors: any; day: any }) => {
+ const { date, visitors, day } = row;
+ if (day === 0) {
+ return arr.concat({
+ date,
+ visitors,
+ records: days
+ .reduce((arr, day) => {
+ arr[day] = data.find(
+ (x: { date: any; day: number }) => x.date === date && x.day === day,
+ );
+ return arr;
+ }, [])
+ .filter(n => n),
+ });
+ }
+ return arr;
+ }, []) || [];
+
+ const totalDays = rows.length;
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ {data && (
+ <Panel allowFullscreen height="900px">
+ <Column
+ paddingY="6"
+ paddingX={{ xs: '3', md: '6' }}
+ position="absolute"
+ top="40px"
+ left="0"
+ right="0"
+ bottom="0"
+ >
+ <Column gap="1" overflow="auto">
+ <Grid
+ columns="120px repeat(10, 100px)"
+ alignItems="center"
+ gap="1"
+ height="50px"
+ width="max-content"
+ minWidth="100%"
+ autoFlow="column"
+ >
+ <Column>
+ <Text weight="bold" align="center">
+ {formatMessage(labels.cohort)}
+ </Text>
+ </Column>
+ {days.map(n => (
+ <Column key={n}>
+ <Text weight="bold" align="center" wrap="nowrap">
+ {formatMessage(labels.day)} {n}
+ </Text>
+ </Column>
+ ))}
+ </Grid>
+ {rows.map(({ date, visitors, records }: any, rowIndex: number) => {
+ return (
+ <Grid
+ key={rowIndex}
+ columns="120px repeat(10, 100px)"
+ gap="1"
+ autoFlow="column"
+ width="max-content"
+ minWidth="100%"
+ >
+ <Column justifyContent="center" gap="1">
+ <Text weight="bold">{formatDate(date, 'PP', locale)}</Text>
+ <Row alignItems="center" gap>
+ <Icon>
+ <Users />
+ </Icon>
+ <Text>{formatLongNumber(visitors)}</Text>
+ </Row>
+ </Column>
+ {days.map(day => {
+ if (totalDays - rowIndex < day) {
+ return null;
+ }
+ const percentage = records.filter(a => a.day === day)[0]?.percentage;
+ return (
+ <Cell key={day}>
+ {percentage ? `${Number(percentage).toFixed(2)}%` : ''}
+ </Cell>
+ );
+ })}
+ </Grid>
+ );
+ })}
+ </Column>
+ </Column>
+ </Panel>
+ )}
+ </LoadingPanel>
+ );
+}
+
+const Cell = ({ children }: { children: ReactNode }) => {
+ return (
+ <Column
+ justifyContent="center"
+ alignItems="center"
+ width="100px"
+ height="100px"
+ backgroundColor="2"
+ borderRadius
+ >
+ {children}
+ </Column>
+ );
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage.tsx
new file mode 100644
index 0000000..0ec6e95
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage.tsx
@@ -0,0 +1,22 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { endOfMonth, startOfMonth } from 'date-fns';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { useDateRange } from '@/components/hooks';
+import { Retention } from './Retention';
+
+export function RetentionPage({ websiteId }: { websiteId: string }) {
+ const {
+ dateRange: { startDate },
+ } = useDateRange();
+
+ const monthStartDate = startOfMonth(startDate);
+ const monthEndDate = endOfMonth(startDate);
+
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} allowDateFilter={false} allowMonthFilter />
+ <Retention websiteId={websiteId} startDate={monthStartDate} endDate={monthEndDate} />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/retention/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/retention/page.tsx
new file mode 100644
index 0000000..2fbbc0a
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/retention/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { RetentionPage } from './RetentionPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <RetentionPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Retention',
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx
new file mode 100644
index 0000000..0e782a1
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx
@@ -0,0 +1,152 @@
+import { Column, Grid, Row, Text } from '@umami/react-zen';
+import classNames from 'classnames';
+import { colord } from 'colord';
+import { useCallback, useMemo, useState } from 'react';
+import { BarChart } from '@/components/charts/BarChart';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { Panel } from '@/components/common/Panel';
+import { TypeIcon } from '@/components/common/TypeIcon';
+import { useCountryNames, useLocale, useMessages, useResultQuery } from '@/components/hooks';
+import { CurrencySelect } from '@/components/input/CurrencySelect';
+import { ListTable } from '@/components/metrics/ListTable';
+import { MetricCard } from '@/components/metrics/MetricCard';
+import { MetricsBar } from '@/components/metrics/MetricsBar';
+import { renderDateLabels } from '@/lib/charts';
+import { CHART_COLORS } from '@/lib/constants';
+import { generateTimeSeries } from '@/lib/date';
+import { formatLongCurrency, formatLongNumber } from '@/lib/format';
+
+export interface RevenueProps {
+ websiteId: string;
+ startDate: Date;
+ endDate: Date;
+ unit: string;
+}
+
+export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
+ const [currency, setCurrency] = useState('USD');
+ const { formatMessage, labels } = useMessages();
+ const { locale, dateLocale } = useLocale();
+ const { countryNames } = useCountryNames(locale);
+ const { data, error, isLoading } = useResultQuery<any>('revenue', {
+ websiteId,
+ startDate,
+ endDate,
+ currency,
+ });
+
+ const renderCountryName = useCallback(
+ ({ label: code }) => (
+ <Row className={classNames(locale)} gap>
+ <TypeIcon type="country" value={code} />
+ <Text>{countryNames[code] || formatMessage(labels.unknown)}</Text>
+ </Row>
+ ),
+ [countryNames, locale],
+ );
+
+ const chartData: any = useMemo(() => {
+ if (!data) return [];
+
+ const map = (data.chart as any[]).reduce((obj, { x, t, y }) => {
+ if (!obj[x]) {
+ obj[x] = [];
+ }
+
+ obj[x].push({ x: t, y });
+
+ return obj;
+ }, {});
+
+ return {
+ datasets: Object.keys(map).map((key, index) => {
+ const color = colord(CHART_COLORS[index % CHART_COLORS.length]);
+ return {
+ label: key,
+ data: generateTimeSeries(map[key], startDate, endDate, unit, dateLocale),
+ lineTension: 0,
+ backgroundColor: color.alpha(0.6).toRgbString(),
+ borderColor: color.alpha(0.7).toRgbString(),
+ borderWidth: 1,
+ };
+ }),
+ };
+ }, [data, startDate, endDate, unit]);
+
+ const metrics = useMemo(() => {
+ if (!data) return [];
+
+ const { sum, count, unique_count } = data.total;
+
+ return [
+ {
+ value: sum,
+ label: formatMessage(labels.total),
+ formatValue: n => formatLongCurrency(n, currency),
+ },
+ {
+ value: count ? sum / count : 0,
+ label: formatMessage(labels.average),
+ formatValue: n => formatLongCurrency(n, currency),
+ },
+ {
+ value: count,
+ label: formatMessage(labels.transactions),
+ formatValue: formatLongNumber,
+ },
+ {
+ value: unique_count,
+ label: formatMessage(labels.uniqueCustomers),
+ formatValue: formatLongNumber,
+ },
+ ] as any;
+ }, [data, locale]);
+
+ const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]);
+
+ return (
+ <Column gap>
+ <Grid columns="280px" gap>
+ <CurrencySelect value={currency} onChange={setCurrency} />
+ </Grid>
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ {data && (
+ <Column gap>
+ <MetricsBar>
+ {metrics?.map(({ label, value, formatValue }) => {
+ return (
+ <MetricCard key={label} value={value} label={label} formatValue={formatValue} />
+ );
+ })}
+ </MetricsBar>
+ <Panel>
+ <BarChart
+ chartData={chartData}
+ minDate={startDate}
+ maxDate={endDate}
+ unit={unit}
+ stacked={true}
+ currency={currency}
+ renderXLabel={renderXLabel}
+ height="400px"
+ />
+ </Panel>
+ <Panel>
+ <ListTable
+ title={formatMessage(labels.country)}
+ metric={formatMessage(labels.revenue)}
+ data={data?.country.map(({ name, value }: { name: string; value: number }) => ({
+ label: name,
+ count: Number(value),
+ percent: (value / data?.total.sum) * 100,
+ }))}
+ currency={currency}
+ renderLabel={renderCountryName}
+ />
+ </Panel>
+ </Column>
+ )}
+ </LoadingPanel>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx
new file mode 100644
index 0000000..3e429c1
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx
@@ -0,0 +1,18 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { useDateRange } from '@/components/hooks';
+import { Revenue } from './Revenue';
+
+export function RevenuePage({ websiteId }: { websiteId: string }) {
+ const {
+ dateRange: { startDate, endDate, unit },
+ } = useDateRange();
+
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} />
+ <Revenue websiteId={websiteId} startDate={startDate} endDate={endDate} unit={unit} />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx
new file mode 100644
index 0000000..e30d54c
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx
@@ -0,0 +1,21 @@
+import { DataColumn, DataTable } from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+import { formatLongCurrency } from '@/lib/format';
+
+export function RevenueTable({ data = [] }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DataTable data={data}>
+ <DataColumn id="currency" label={formatMessage(labels.currency)} align="end" />
+ <DataColumn id="total" label={formatMessage(labels.total)} align="end">
+ {(row: any) => formatLongCurrency(row.sum, row.currency)}
+ </DataColumn>
+ <DataColumn id="average" label={formatMessage(labels.average)} align="end">
+ {(row: any) => formatLongCurrency(row.count ? row.sum / row.count : 0, row.currency)}
+ </DataColumn>
+ <DataColumn id="count" label={formatMessage(labels.transactions)} align="end" />
+ <DataColumn id="unique_count" label={formatMessage(labels.uniqueCustomers)} align="end" />
+ </DataTable>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/page.tsx
new file mode 100644
index 0000000..fba10f1
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { RevenuePage } from './RevenuePage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <RevenuePage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Revenue',
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/utm/UTM.tsx b/src/app/(main)/websites/[websiteId]/(reports)/utm/UTM.tsx
new file mode 100644
index 0000000..1399174
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/utm/UTM.tsx
@@ -0,0 +1,71 @@
+import { Column, Grid, Heading, Text } from '@umami/react-zen';
+import { PieChart } from '@/components/charts/PieChart';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { Panel } from '@/components/common/Panel';
+import { useMessages, useResultQuery } from '@/components/hooks';
+import { ListTable } from '@/components/metrics/ListTable';
+import { CHART_COLORS, UTM_PARAMS } from '@/lib/constants';
+
+export interface UTMProps {
+ websiteId: string;
+ startDate: Date;
+ endDate: Date;
+}
+
+export function UTM({ websiteId, startDate, endDate }: UTMProps) {
+ const { formatMessage, labels } = useMessages();
+ const { data, error, isLoading } = useResultQuery<any>('utm', {
+ websiteId,
+ startDate,
+ endDate,
+ });
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error} minHeight="300px">
+ {data && (
+ <Column gap>
+ {UTM_PARAMS.map(param => {
+ const items = data?.[param];
+
+ const chartData = {
+ labels: items.map(({ utm }) => utm),
+ datasets: [
+ {
+ data: items.map(({ views }) => views),
+ backgroundColor: CHART_COLORS,
+ borderWidth: 0,
+ },
+ ],
+ };
+ const total = items.reduce((sum, { views }) => {
+ return +sum + +views;
+ }, 0);
+
+ return (
+ <Panel key={param}>
+ <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap="6">
+ <Column>
+ <Heading>
+ <Text transform="capitalize">{param.replace(/^utm_/, '')}</Text>
+ </Heading>
+ <ListTable
+ metric={formatMessage(labels.views)}
+ data={items.map(({ utm, views }) => ({
+ label: utm,
+ count: views,
+ percent: (views / total) * 100,
+ }))}
+ />
+ </Column>
+ <Column>
+ <PieChart type="doughnut" chartData={chartData} />
+ </Column>
+ </Grid>
+ </Panel>
+ );
+ })}
+ </Column>
+ )}
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage.tsx
new file mode 100644
index 0000000..0d2a732
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage.tsx
@@ -0,0 +1,18 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { useDateRange } from '@/components/hooks';
+import { UTM } from './UTM';
+
+export function UTMPage({ websiteId }: { websiteId: string }) {
+ const {
+ dateRange: { startDate, endDate },
+ } = useDateRange();
+
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} />
+ <UTM websiteId={websiteId} startDate={startDate} endDate={endDate} />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/utm/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/utm/page.tsx
new file mode 100644
index 0000000..8b8fd6a
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/utm/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { UTMPage } from './UTMPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <UTMPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'UTM Parameters',
+};
diff --git a/src/app/(main)/websites/[websiteId]/ExpandedViewModal.tsx b/src/app/(main)/websites/[websiteId]/ExpandedViewModal.tsx
new file mode 100644
index 0000000..3663812
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/ExpandedViewModal.tsx
@@ -0,0 +1,52 @@
+import { Dialog, Modal } from '@umami/react-zen';
+import { WebsiteExpandedView } from '@/app/(main)/websites/[websiteId]/WebsiteExpandedView';
+import { useMobile, useNavigation } from '@/components/hooks';
+
+export function ExpandedViewModal({
+ websiteId,
+ excludedIds,
+}: {
+ websiteId: string;
+ excludedIds?: string[];
+}) {
+ const {
+ router,
+ query: { view },
+ updateParams,
+ } = useNavigation();
+ const { isMobile } = useMobile();
+
+ const handleClose = (close: () => void) => {
+ router.push(updateParams({ view: undefined }));
+ close();
+ };
+
+ const handleOpenChange = (isOpen: boolean) => {
+ if (!isOpen) {
+ router.push(updateParams({ view: undefined }));
+ }
+ };
+
+ return (
+ <Modal isOpen={!!view} onOpenChange={handleOpenChange} isDismissable>
+ <Dialog
+ style={{
+ maxWidth: 1320,
+ width: '100vw',
+ height: isMobile ? '100dvh' : 'calc(100dvh - 40px)',
+ overflow: 'hidden',
+ }}
+ >
+ {({ close }) => {
+ return (
+ <WebsiteExpandedView
+ websiteId={websiteId}
+ excludedIds={excludedIds}
+ onClose={() => handleClose(close)}
+ />
+ );
+ }}
+ </Dialog>
+ </Modal>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx b/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx
new file mode 100644
index 0000000..b2ea2a8
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx
@@ -0,0 +1,61 @@
+import { useMemo } from 'react';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useDateRange, useTimezone } from '@/components/hooks';
+import { useWebsitePageviewsQuery } from '@/components/hooks/queries/useWebsitePageviewsQuery';
+import { PageviewsChart } from '@/components/metrics/PageviewsChart';
+
+export function WebsiteChart({
+ websiteId,
+ compareMode,
+}: {
+ websiteId: string;
+ compareMode?: boolean;
+}) {
+ const { timezone } = useTimezone();
+ const { dateRange, dateCompare } = useDateRange({ timezone: timezone });
+ const { startDate, endDate, unit, value } = dateRange;
+ const { data, isLoading, isFetching, error } = useWebsitePageviewsQuery({
+ websiteId,
+ compare: compareMode ? dateCompare?.compare : undefined,
+ });
+ const { pageviews, sessions, compare } = (data || {}) as any;
+
+ const chartData = useMemo(() => {
+ if (data) {
+ const result = {
+ pageviews,
+ sessions,
+ };
+
+ if (compare) {
+ result.compare = {
+ pageviews: result.pageviews.map(({ x }, i) => ({
+ x,
+ y: compare.pageviews[i]?.y,
+ d: compare.pageviews[i]?.x,
+ })),
+ sessions: result.sessions.map(({ x }, i) => ({
+ x,
+ y: compare.sessions[i]?.y,
+ d: compare.sessions[i]?.x,
+ })),
+ };
+ }
+
+ return result;
+ }
+ return { pageviews: [], sessions: [] };
+ }, [data, startDate, endDate, unit]);
+
+ return (
+ <LoadingPanel data={data} isFetching={isFetching} isLoading={isLoading} error={error}>
+ <PageviewsChart
+ key={value}
+ data={chartData}
+ minDate={startDate}
+ maxDate={endDate}
+ unit={unit}
+ />
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteControls.tsx b/src/app/(main)/websites/[websiteId]/WebsiteControls.tsx
new file mode 100644
index 0000000..6223dbc
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/WebsiteControls.tsx
@@ -0,0 +1,40 @@
+import { Column, Grid, Row } from '@umami/react-zen';
+import { ExportButton } from '@/components/input/ExportButton';
+import { FilterBar } from '@/components/input/FilterBar';
+import { MonthFilter } from '@/components/input/MonthFilter';
+import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
+import { WebsiteFilterButton } from '@/components/input/WebsiteFilterButton';
+
+export function WebsiteControls({
+ websiteId,
+ allowFilter = true,
+ allowDateFilter = true,
+ allowMonthFilter,
+ allowDownload = false,
+ allowCompare = false,
+}: {
+ websiteId: string;
+ allowFilter?: boolean;
+ allowDateFilter?: boolean;
+ allowMonthFilter?: boolean;
+ allowDownload?: boolean;
+ allowCompare?: boolean;
+}) {
+ return (
+ <Column gap>
+ <Grid columns={{ xs: '1fr', md: 'auto 1fr' }} gap>
+ <Row alignItems="center" justifyContent="flex-start">
+ {allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />}
+ </Row>
+ <Row alignItems="center" justifyContent={{ xs: 'flex-start', md: 'flex-end' }}>
+ {allowDateFilter && (
+ <WebsiteDateFilter websiteId={websiteId} allowCompare={allowCompare} />
+ )}
+ {allowDownload && <ExportButton websiteId={websiteId} />}
+ {allowMonthFilter && <MonthFilter />}
+ </Row>
+ </Grid>
+ {allowFilter && <FilterBar websiteId={websiteId} />}
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteExpandedMenu.tsx b/src/app/(main)/websites/[websiteId]/WebsiteExpandedMenu.tsx
new file mode 100644
index 0000000..29c3954
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/WebsiteExpandedMenu.tsx
@@ -0,0 +1,183 @@
+import { SideMenu } from '@/components/common/SideMenu';
+import { useMessages, useNavigation } from '@/components/hooks';
+import {
+ AppWindow,
+ Cpu,
+ Earth,
+ Globe,
+ Landmark,
+ Languages,
+ Laptop,
+ LogIn,
+ LogOut,
+ MapPin,
+ Megaphone,
+ Monitor,
+ Network,
+ Search,
+ Share2,
+ SquareSlash,
+ Tag,
+ Type,
+} from '@/components/icons';
+import { Lightning } from '@/components/svg';
+
+export function WebsiteExpandedMenu({
+ excludedIds = [],
+ onItemClick,
+}: {
+ excludedIds?: string[];
+ onItemClick?: () => void;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const {
+ updateParams,
+ query: { view },
+ } = useNavigation();
+
+ const filterExcluded = (item: { id: string }) => !excludedIds.includes(item.id);
+
+ const items = [
+ {
+ label: 'URL',
+ items: [
+ {
+ id: 'path',
+ label: formatMessage(labels.path),
+ path: updateParams({ view: 'path' }),
+ icon: <SquareSlash />,
+ },
+ {
+ id: 'entry',
+ label: formatMessage(labels.entry),
+ path: updateParams({ view: 'entry' }),
+ icon: <LogIn />,
+ },
+ {
+ id: 'exit',
+ label: formatMessage(labels.exit),
+ path: updateParams({ view: 'exit' }),
+ icon: <LogOut />,
+ },
+ {
+ id: 'title',
+ label: formatMessage(labels.title),
+ path: updateParams({ view: 'title' }),
+ icon: <Type />,
+ },
+ {
+ id: 'query',
+ label: formatMessage(labels.query),
+ path: updateParams({ view: 'query' }),
+ icon: <Search />,
+ },
+ ].filter(filterExcluded),
+ },
+ {
+ label: formatMessage(labels.sources),
+ items: [
+ {
+ id: 'referrer',
+ label: formatMessage(labels.referrer),
+ path: updateParams({ view: 'referrer' }),
+ icon: <Share2 />,
+ },
+ {
+ id: 'channel',
+ label: formatMessage(labels.channel),
+ path: updateParams({ view: 'channel' }),
+ icon: <Megaphone />,
+ },
+ {
+ id: 'domain',
+ label: formatMessage(labels.domain),
+ path: updateParams({ view: 'domain' }),
+ icon: <Globe />,
+ },
+ ].filter(filterExcluded),
+ },
+ {
+ label: formatMessage(labels.location),
+ items: [
+ {
+ id: 'country',
+ label: formatMessage(labels.country),
+ path: updateParams({ view: 'country' }),
+ icon: <Earth />,
+ },
+ {
+ id: 'region',
+ label: formatMessage(labels.region),
+ path: updateParams({ view: 'region' }),
+ icon: <MapPin />,
+ },
+ {
+ id: 'city',
+ label: formatMessage(labels.city),
+ path: updateParams({ view: 'city' }),
+ icon: <Landmark />,
+ },
+ ].filter(filterExcluded),
+ },
+ {
+ label: formatMessage(labels.environment),
+ items: [
+ {
+ id: 'browser',
+ label: formatMessage(labels.browser),
+ path: updateParams({ view: 'browser' }),
+ icon: <AppWindow />,
+ },
+ {
+ id: 'os',
+ label: formatMessage(labels.os),
+ path: updateParams({ view: 'os' }),
+ icon: <Cpu />,
+ },
+ {
+ id: 'device',
+ label: formatMessage(labels.device),
+ path: updateParams({ view: 'device' }),
+ icon: <Laptop />,
+ },
+ {
+ id: 'language',
+ label: formatMessage(labels.language),
+ path: updateParams({ view: 'language' }),
+ icon: <Languages />,
+ },
+ {
+ id: 'screen',
+ label: formatMessage(labels.screen),
+ path: updateParams({ view: 'screen' }),
+ icon: <Monitor />,
+ },
+ ].filter(filterExcluded),
+ },
+ {
+ label: formatMessage(labels.other),
+ items: [
+ {
+ id: 'event',
+ label: formatMessage(labels.event),
+ path: updateParams({ view: 'event' }),
+ icon: <Lightning />,
+ },
+ {
+ id: 'hostname',
+ label: formatMessage(labels.hostname),
+ path: updateParams({ view: 'hostname' }),
+ icon: <Network />,
+ },
+ {
+ id: 'tag',
+ label: formatMessage(labels.tag),
+ path: updateParams({ view: 'tag' }),
+ icon: <Tag />,
+ },
+ ].filter(filterExcluded),
+ },
+ ];
+
+ return <SideMenu items={items} selectedKey={view} onItemClick={onItemClick} />;
+}
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx b/src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx
new file mode 100644
index 0000000..2c670df
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx
@@ -0,0 +1,57 @@
+import { Column, Grid, Row } from '@umami/react-zen';
+import { WebsiteExpandedMenu } from '@/app/(main)/websites/[websiteId]/WebsiteExpandedMenu';
+import { useMessages, useNavigation } from '@/components/hooks';
+import { MobileMenuButton } from '@/components/input/MobileMenuButton';
+import { MetricsExpandedTable } from '@/components/metrics/MetricsExpandedTable';
+
+export function WebsiteExpandedView({
+ websiteId,
+ excludedIds = [],
+ onClose,
+}: {
+ websiteId: string;
+ excludedIds?: string[];
+ onClose?: () => void;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const {
+ query: { view },
+ } = useNavigation();
+
+ return (
+ <Column height="100%" overflow="hidden" gap>
+ <Row id="expanded-mobile-menu-button" display={{ xs: 'flex', md: 'none' }}>
+ <MobileMenuButton>
+ {({ close }) => {
+ return (
+ <Column padding="3">
+ <WebsiteExpandedMenu excludedIds={excludedIds} onItemClick={close} />
+ </Column>
+ );
+ }}
+ </MobileMenuButton>
+ </Row>
+ <Grid columns={{ xs: '1fr', md: 'auto 1fr' }} gap="6" overflow="hidden">
+ <Column
+ id="metrics-expanded-menu"
+ display={{ xs: 'none', md: 'flex' }}
+ width="240px"
+ gap="6"
+ border="right"
+ paddingRight="3"
+ overflow="auto"
+ >
+ <WebsiteExpandedMenu excludedIds={excludedIds} />
+ </Column>
+ <Column id="metrics-expanded-table" overflow="hidden">
+ <MetricsExpandedTable
+ title={formatMessage(labels[view])}
+ type={view}
+ websiteId={websiteId}
+ onClose={onClose}
+ />
+ </Column>
+ </Grid>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx b/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx
new file mode 100644
index 0000000..7dd1d77
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx
@@ -0,0 +1,57 @@
+import { Icon, Row, Text } from '@umami/react-zen';
+import { WebsiteShareForm } from '@/app/(main)/websites/[websiteId]/settings/WebsiteShareForm';
+import { Favicon } from '@/components/common/Favicon';
+import { LinkButton } from '@/components/common/LinkButton';
+import { PageHeader } from '@/components/common/PageHeader';
+import { useMessages, useNavigation, useWebsite } from '@/components/hooks';
+import { Edit, Share } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { ActiveUsers } from '@/components/metrics/ActiveUsers';
+
+export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
+ const website = useWebsite();
+ const { renderUrl, pathname } = useNavigation();
+ const isSettings = pathname.endsWith('/settings');
+
+ const { formatMessage, labels } = useMessages();
+
+ if (isSettings) {
+ return null;
+ }
+
+ return (
+ <PageHeader
+ title={website.name}
+ icon={<Favicon domain={website.domain} />}
+ titleHref={renderUrl(`/websites/${website.id}`, false)}
+ >
+ <Row alignItems="center" gap="6" wrap="wrap">
+ <ActiveUsers websiteId={website.id} />
+
+ {showActions && (
+ <Row alignItems="center" gap>
+ <ShareButton websiteId={website.id} shareId={website.shareId} />
+ <LinkButton href={renderUrl(`/websites/${website.id}/settings`, false)}>
+ <Icon>
+ <Edit />
+ </Icon>
+ <Text>{formatMessage(labels.edit)}</Text>
+ </LinkButton>
+ </Row>
+ )}
+ </Row>
+ </PageHeader>
+ );
+}
+
+const ShareButton = ({ websiteId, shareId }) => {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogButton icon={<Share />} label={formatMessage(labels.share)} width="800px">
+ {({ close }) => {
+ return <WebsiteShareForm websiteId={websiteId} shareId={shareId} onClose={close} />;
+ }}
+ </DialogButton>
+ );
+};
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteLayout.tsx b/src/app/(main)/websites/[websiteId]/WebsiteLayout.tsx
new file mode 100644
index 0000000..7260a7e
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/WebsiteLayout.tsx
@@ -0,0 +1,30 @@
+'use client';
+import { Column, Grid } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
+import { PageBody } from '@/components/common/PageBody';
+import { WebsiteHeader } from './WebsiteHeader';
+import { WebsiteNav } from './WebsiteNav';
+
+export function WebsiteLayout({ websiteId, children }: { websiteId: string; children: ReactNode }) {
+ return (
+ <WebsiteProvider websiteId={websiteId}>
+ <Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%" height="100%">
+ <Column
+ display={{ xs: 'none', lg: 'flex' }}
+ width="240px"
+ height="100%"
+ border="right"
+ backgroundColor
+ marginRight="2"
+ >
+ <WebsiteNav websiteId={websiteId} />
+ </Column>
+ <PageBody gap>
+ <WebsiteHeader showActions />
+ <Column>{children}</Column>
+ </PageBody>
+ </Grid>
+ </WebsiteProvider>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteMenu.tsx b/src/app/(main)/websites/[websiteId]/WebsiteMenu.tsx
new file mode 100644
index 0000000..3018953
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/WebsiteMenu.tsx
@@ -0,0 +1,56 @@
+import {
+ Button,
+ Icon,
+ Menu,
+ MenuItem,
+ MenuSeparator,
+ MenuTrigger,
+ Popover,
+ Text,
+} from '@umami/react-zen';
+import { Fragment } from 'react';
+import { useMessages, useNavigation } from '@/components/hooks';
+import { Edit, More, Share } from '@/components/icons';
+
+export function WebsiteMenu({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+ const { router, updateParams, renderUrl } = useNavigation();
+
+ const menuItems = [
+ { id: 'share', label: formatMessage(labels.share), icon: <Share /> },
+ { id: 'edit', label: formatMessage(labels.edit), icon: <Edit />, seperator: true },
+ ];
+
+ const handleAction = (id: any) => {
+ if (id === 'compare') {
+ router.push(updateParams({ compare: 'prev' }));
+ } else if (id === 'edit') {
+ router.push(renderUrl(`/websites/${websiteId}`));
+ }
+ };
+
+ return (
+ <MenuTrigger>
+ <Button variant="quiet">
+ <Icon>
+ <More />
+ </Icon>
+ </Button>
+ <Popover placement="bottom">
+ <Menu onAction={handleAction}>
+ {menuItems.map(({ id, label, icon, seperator }, index) => {
+ return (
+ <Fragment key={index}>
+ {seperator && <MenuSeparator />}
+ <MenuItem id={id}>
+ <Icon>{icon}</Icon>
+ <Text>{label}</Text>
+ </MenuItem>
+ </Fragment>
+ );
+ })}
+ </Menu>
+ </Popover>
+ </MenuTrigger>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx
new file mode 100644
index 0000000..6c91ba6
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx
@@ -0,0 +1,88 @@
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useDateRange, useMessages } from '@/components/hooks';
+import { useWebsiteStatsQuery } from '@/components/hooks/queries/useWebsiteStatsQuery';
+import { MetricCard } from '@/components/metrics/MetricCard';
+import { MetricsBar } from '@/components/metrics/MetricsBar';
+import { formatLongNumber, formatShortTime } from '@/lib/format';
+
+export function WebsiteMetricsBar({
+ websiteId,
+}: {
+ websiteId: string;
+ showChange?: boolean;
+ compareMode?: boolean;
+}) {
+ const { isAllTime } = useDateRange();
+ const { formatMessage, labels, getErrorMessage } = useMessages();
+ const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(websiteId);
+
+ const { pageviews, visitors, visits, bounces, totaltime, comparison } = data || {};
+
+ const metrics = data
+ ? [
+ {
+ value: visitors,
+ label: formatMessage(labels.visitors),
+ change: visitors - comparison.visitors,
+ formatValue: formatLongNumber,
+ },
+ {
+ value: visits,
+ label: formatMessage(labels.visits),
+ change: visits - comparison.visits,
+ formatValue: formatLongNumber,
+ },
+ {
+ value: pageviews,
+ label: formatMessage(labels.views),
+ change: pageviews - comparison.pageviews,
+ formatValue: formatLongNumber,
+ },
+ {
+ label: formatMessage(labels.bounceRate),
+ value: (Math.min(visits, bounces) / visits) * 100,
+ prev: (Math.min(comparison.visits, comparison.bounces) / comparison.visits) * 100,
+ change:
+ (Math.min(visits, bounces) / visits) * 100 -
+ (Math.min(comparison.visits, comparison.bounces) / comparison.visits) * 100,
+ formatValue: n => `${Math.round(+n)}%`,
+ reverseColors: true,
+ },
+ {
+ label: formatMessage(labels.visitDuration),
+ value: totaltime / visits,
+ prev: comparison.totaltime / comparison.visits,
+ change: totaltime / visits - comparison.totaltime / comparison.visits,
+ formatValue: n =>
+ `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`,
+ },
+ ]
+ : null;
+
+ return (
+ <LoadingPanel
+ data={metrics}
+ isLoading={isLoading}
+ isFetching={isFetching}
+ error={getErrorMessage(error)}
+ minHeight="136px"
+ >
+ <MetricsBar>
+ {metrics?.map(({ label, value, prev, change, formatValue, reverseColors }) => {
+ return (
+ <MetricCard
+ key={label}
+ value={value}
+ previousValue={prev}
+ label={label}
+ change={change}
+ formatValue={formatValue}
+ reverseColors={reverseColors}
+ showChange={!isAllTime}
+ />
+ );
+ })}
+ </MetricsBar>
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteNav.tsx b/src/app/(main)/websites/[websiteId]/WebsiteNav.tsx
new file mode 100644
index 0000000..ad05b70
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/WebsiteNav.tsx
@@ -0,0 +1,180 @@
+import { Column, Text } from '@umami/react-zen';
+import { SideMenu } from '@/components/common/SideMenu';
+import { useMessages, useNavigation } from '@/components/hooks';
+import {
+ AlignEndHorizontal,
+ ChartPie,
+ Clock,
+ Eye,
+ Sheet,
+ Tag,
+ User,
+ UserPlus,
+} from '@/components/icons';
+import { WebsiteSelect } from '@/components/input/WebsiteSelect';
+import { Funnel, Lightning, Magnet, Money, Network, Path, Target } from '@/components/svg';
+
+export function WebsiteNav({
+ websiteId,
+ onItemClick,
+}: {
+ websiteId: string;
+ onItemClick?: () => void;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { pathname, renderUrl, teamId, router } = useNavigation();
+
+ const renderPath = (path: string) =>
+ renderUrl(`/websites/${websiteId}${path}`, {
+ event: undefined,
+ compare: undefined,
+ view: undefined,
+ });
+
+ const items = [
+ {
+ label: formatMessage(labels.traffic),
+ items: [
+ {
+ id: 'overview',
+ label: formatMessage(labels.overview),
+ icon: <Eye />,
+ path: renderPath(''),
+ },
+ {
+ id: 'events',
+ label: formatMessage(labels.events),
+ icon: <Lightning />,
+ path: renderPath('/events'),
+ },
+ {
+ id: 'sessions',
+ label: formatMessage(labels.sessions),
+ icon: <User />,
+ path: renderPath('/sessions'),
+ },
+ {
+ id: 'realtime',
+ label: formatMessage(labels.realtime),
+ icon: <Clock />,
+ path: renderPath('/realtime'),
+ },
+ {
+ id: 'compare',
+ label: formatMessage(labels.compare),
+ icon: <AlignEndHorizontal />,
+ path: renderPath('/compare'),
+ },
+ {
+ id: 'breakdown',
+ label: formatMessage(labels.breakdown),
+ icon: <Sheet />,
+ path: renderPath('/breakdown'),
+ },
+ ],
+ },
+ {
+ label: formatMessage(labels.behavior),
+ items: [
+ {
+ id: 'goals',
+ label: formatMessage(labels.goals),
+ icon: <Target />,
+ path: renderPath('/goals'),
+ },
+ {
+ id: 'funnel',
+ label: formatMessage(labels.funnels),
+ icon: <Funnel />,
+ path: renderPath('/funnels'),
+ },
+ {
+ id: 'journeys',
+ label: formatMessage(labels.journeys),
+ icon: <Path />,
+ path: renderPath('/journeys'),
+ },
+ {
+ id: 'retention',
+ label: formatMessage(labels.retention),
+ icon: <Magnet />,
+ path: renderPath('/retention'),
+ },
+ ],
+ },
+ {
+ label: formatMessage(labels.audience),
+ items: [
+ {
+ id: 'segments',
+ label: formatMessage(labels.segments),
+ icon: <ChartPie />,
+ path: renderPath('/segments'),
+ },
+ {
+ id: 'cohorts',
+ label: formatMessage(labels.cohorts),
+ icon: <UserPlus />,
+ path: renderPath('/cohorts'),
+ },
+ ],
+ },
+ {
+ label: formatMessage(labels.growth),
+ items: [
+ {
+ id: 'utm',
+ label: formatMessage(labels.utm),
+ icon: <Tag />,
+ path: renderPath('/utm'),
+ },
+ {
+ id: 'revenue',
+ label: formatMessage(labels.revenue),
+ icon: <Money />,
+ path: renderPath('/revenue'),
+ },
+ {
+ id: 'attribution',
+ label: formatMessage(labels.attribution),
+ icon: <Network />,
+ path: renderPath('/attribution'),
+ },
+ ],
+ },
+ ];
+
+ const handleChange = (value: string) => {
+ router.push(renderUrl(`/websites/${value}`));
+ };
+
+ const renderValue = (value: any) => {
+ return (
+ <Text truncate style={{ maxWidth: 160, lineHeight: 1 }}>
+ {value?.selectedItem?.name}
+ </Text>
+ );
+ };
+
+ const selectedKey = items
+ .flatMap(e => e.items)
+ .find(({ path }) => path && pathname.endsWith(path.split('?')[0]))?.id;
+
+ return (
+ <Column padding="3" position="sticky" top="0" gap>
+ <WebsiteSelect
+ websiteId={websiteId}
+ teamId={teamId}
+ onChange={handleChange}
+ renderValue={renderValue}
+ buttonProps={{ style: { outline: 'none' } }}
+ />
+ <SideMenu
+ items={items}
+ selectedKey={selectedKey}
+ allowMinimize={false}
+ onItemClick={onItemClick}
+ />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/WebsitePage.tsx b/src/app/(main)/websites/[websiteId]/WebsitePage.tsx
new file mode 100644
index 0000000..f587e11
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/WebsitePage.tsx
@@ -0,0 +1,22 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal';
+import { Panel } from '@/components/common/Panel';
+import { WebsiteChart } from './WebsiteChart';
+import { WebsiteControls } from './WebsiteControls';
+import { WebsiteMetricsBar } from './WebsiteMetricsBar';
+import { WebsitePanels } from './WebsitePanels';
+
+export function WebsitePage({ websiteId }: { websiteId: string }) {
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} />
+ <WebsiteMetricsBar websiteId={websiteId} showChange={true} />
+ <Panel minHeight="520px">
+ <WebsiteChart websiteId={websiteId} />
+ </Panel>
+ <WebsitePanels websiteId={websiteId} />
+ <ExpandedViewModal websiteId={websiteId} />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/WebsitePanels.tsx b/src/app/(main)/websites/[websiteId]/WebsitePanels.tsx
new file mode 100644
index 0000000..a91d562
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/WebsitePanels.tsx
@@ -0,0 +1,140 @@
+import { Grid, Heading, Row, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
+import { GridRow } from '@/components/common/GridRow';
+import { Panel } from '@/components/common/Panel';
+import { useMessages, useNavigation } from '@/components/hooks';
+import { EventsChart } from '@/components/metrics/EventsChart';
+import { MetricsTable } from '@/components/metrics/MetricsTable';
+import { WeeklyTraffic } from '@/components/metrics/WeeklyTraffic';
+import { WorldMap } from '@/components/metrics/WorldMap';
+
+export function WebsitePanels({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+ const { pathname } = useNavigation();
+ const tableProps = {
+ websiteId,
+ limit: 10,
+ allowDownload: false,
+ showMore: true,
+ metric: formatMessage(labels.visitors),
+ };
+ const rowProps = { minHeight: '570px' };
+ const isSharePage = pathname.includes('/share/');
+
+ return (
+ <Grid gap="3">
+ <GridRow layout="two" {...rowProps}>
+ <Panel>
+ <Heading size="2">{formatMessage(labels.pages)}</Heading>
+ <Tabs>
+ <TabList>
+ <Tab id="path">{formatMessage(labels.path)}</Tab>
+ <Tab id="entry">{formatMessage(labels.entry)}</Tab>
+ <Tab id="exit">{formatMessage(labels.exit)}</Tab>
+ </TabList>
+ <TabPanel id="path">
+ <MetricsTable type="path" title={formatMessage(labels.path)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="entry">
+ <MetricsTable type="entry" title={formatMessage(labels.path)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="exit">
+ <MetricsTable type="exit" title={formatMessage(labels.path)} {...tableProps} />
+ </TabPanel>
+ </Tabs>
+ </Panel>
+ <Panel>
+ <Heading size="2">{formatMessage(labels.sources)}</Heading>
+ <Tabs>
+ <TabList>
+ <Tab id="referrer">{formatMessage(labels.referrers)}</Tab>
+ <Tab id="channel">{formatMessage(labels.channels)}</Tab>
+ </TabList>
+ <TabPanel id="referrer">
+ <MetricsTable
+ type="referrer"
+ title={formatMessage(labels.referrer)}
+ {...tableProps}
+ />
+ </TabPanel>
+ <TabPanel id="channel">
+ <MetricsTable type="channel" title={formatMessage(labels.channel)} {...tableProps} />
+ </TabPanel>
+ </Tabs>
+ </Panel>
+ </GridRow>
+
+ <GridRow layout="two" {...rowProps}>
+ <Panel>
+ <Heading size="2">{formatMessage(labels.environment)}</Heading>
+ <Tabs>
+ <TabList>
+ <Tab id="browser">{formatMessage(labels.browsers)}</Tab>
+ <Tab id="os">{formatMessage(labels.os)}</Tab>
+ <Tab id="device">{formatMessage(labels.devices)}</Tab>
+ </TabList>
+ <TabPanel id="browser">
+ <MetricsTable type="browser" title={formatMessage(labels.browser)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="os">
+ <MetricsTable type="os" title={formatMessage(labels.os)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="device">
+ <MetricsTable type="device" title={formatMessage(labels.device)} {...tableProps} />
+ </TabPanel>
+ </Tabs>
+ </Panel>
+
+ <Panel>
+ <Heading size="2">{formatMessage(labels.location)}</Heading>
+ <Tabs>
+ <TabList>
+ <Tab id="country">{formatMessage(labels.countries)}</Tab>
+ <Tab id="region">{formatMessage(labels.regions)}</Tab>
+ <Tab id="city">{formatMessage(labels.cities)}</Tab>
+ </TabList>
+ <TabPanel id="country">
+ <MetricsTable type="country" title={formatMessage(labels.country)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="region">
+ <MetricsTable type="region" title={formatMessage(labels.region)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="city">
+ <MetricsTable type="city" title={formatMessage(labels.city)} {...tableProps} />
+ </TabPanel>
+ </Tabs>
+ </Panel>
+ </GridRow>
+
+ <GridRow layout="two-one" {...rowProps}>
+ <Panel gridColumn={{ xs: 'span 1', md: 'span 2' }} paddingX="0" paddingY="0">
+ <WorldMap websiteId={websiteId} />
+ </Panel>
+
+ <Panel>
+ <Heading size="2">{formatMessage(labels.traffic)}</Heading>
+ <Row border="bottom" marginBottom="4" />
+ <WeeklyTraffic websiteId={websiteId} />
+ </Panel>
+ </GridRow>
+ {isSharePage && (
+ <GridRow layout="two-one" {...rowProps}>
+ <Panel>
+ <Heading size="2">{formatMessage(labels.events)}</Heading>
+ <Row border="bottom" marginBottom="4" />
+ <MetricsTable
+ websiteId={websiteId}
+ type="event"
+ title={formatMessage(labels.event)}
+ metric={formatMessage(labels.count)}
+ limit={15}
+ filterLink={false}
+ />
+ </Panel>
+ <Panel gridColumn={{ xs: 'span 1', md: 'span 2' }}>
+ <EventsChart websiteId={websiteId} />
+ </Panel>
+ </GridRow>
+ )}
+ </Grid>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteTabs.tsx b/src/app/(main)/websites/[websiteId]/WebsiteTabs.tsx
new file mode 100644
index 0000000..ac978a2
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/WebsiteTabs.tsx
@@ -0,0 +1,64 @@
+import { Icon, Row, Tab, TabList, Tabs, Text } from '@umami/react-zen';
+import { useMessages, useNavigation, useWebsite } from '@/components/hooks';
+import { ChartPie, Clock, Eye, User } from '@/components/icons';
+import { Lightning } from '@/components/svg';
+
+export function WebsiteTabs() {
+ const website = useWebsite();
+ const { pathname, renderUrl } = useNavigation();
+ const { formatMessage, labels } = useMessages();
+
+ const links = [
+ {
+ id: 'overview',
+ label: formatMessage(labels.overview),
+ icon: <Eye />,
+ path: '',
+ },
+ {
+ id: 'events',
+ label: formatMessage(labels.events),
+ icon: <Lightning />,
+ path: '/events',
+ },
+ {
+ id: 'sessions',
+ label: formatMessage(labels.sessions),
+ icon: <User />,
+ path: '/sessions',
+ },
+ {
+ id: 'realtime',
+ label: formatMessage(labels.realtime),
+ icon: <Clock />,
+ path: '/realtime',
+ },
+ {
+ id: 'reports',
+ label: formatMessage(labels.reports),
+ icon: <ChartPie />,
+ path: '/reports',
+ },
+ ];
+
+ const selectedKey = links.find(({ path }) => path && pathname.includes(path))?.id || 'overview';
+
+ return (
+ <Row marginBottom="6">
+ <Tabs selectedKey={selectedKey}>
+ <TabList>
+ {links.map(({ id, label, icon, path }) => {
+ return (
+ <Tab key={id} id={id} href={renderUrl(`/websites/${website.id}${path}`)}>
+ <Row alignItems="center" gap>
+ <Icon>{icon}</Icon>
+ <Text>{label}</Text>
+ </Row>
+ </Tab>
+ );
+ })}
+ </TabList>
+ </Tabs>
+ </Row>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortAddButton.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortAddButton.tsx
new file mode 100644
index 0000000..3f7f872
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortAddButton.tsx
@@ -0,0 +1,21 @@
+import { useMessages } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { CohortEditForm } from './CohortEditForm';
+
+export function CohortAddButton({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogButton
+ icon={<Plus />}
+ label={formatMessage(labels.cohort)}
+ variant="primary"
+ width="800px"
+ >
+ {({ close }) => {
+ return <CohortEditForm websiteId={websiteId} onClose={close} />;
+ }}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortDeleteButton.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortDeleteButton.tsx
new file mode 100644
index 0000000..94d62ff
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortDeleteButton.tsx
@@ -0,0 +1,60 @@
+import { ConfirmationForm } from '@/components/common/ConfirmationForm';
+import { useDeleteQuery, useMessages } from '@/components/hooks';
+import { Trash } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { messages } from '@/components/messages';
+
+export function CohortDeleteButton({
+ cohortId,
+ websiteId,
+ name,
+ onSave,
+}: {
+ cohortId: string;
+ websiteId: string;
+ name: string;
+ onSave?: () => void;
+}) {
+ const { formatMessage, labels, FormattedMessage } = useMessages();
+ const { mutateAsync, isPending, error, touch } = useDeleteQuery(
+ `/websites/${websiteId}/segments/${cohortId}`,
+ );
+
+ const handleConfirm = async (close: () => void) => {
+ await mutateAsync(null, {
+ onSuccess: () => {
+ touch('cohorts');
+ onSave?.();
+ close();
+ },
+ });
+ };
+
+ return (
+ <DialogButton
+ icon={<Trash />}
+ variant="quiet"
+ title={formatMessage(labels.confirm)}
+ width="400px"
+ >
+ {({ close }) => (
+ <ConfirmationForm
+ message={
+ <FormattedMessage
+ {...messages.confirmRemove}
+ values={{
+ target: <b>{name}</b>,
+ }}
+ />
+ }
+ isLoading={isPending}
+ error={error}
+ onConfirm={handleConfirm.bind(null, close)}
+ onClose={close}
+ buttonLabel={formatMessage(labels.delete)}
+ buttonVariant="danger"
+ />
+ )}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortEditButton.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortEditButton.tsx
new file mode 100644
index 0000000..0799071
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortEditButton.tsx
@@ -0,0 +1,37 @@
+import { CohortEditForm } from '@/app/(main)/websites/[websiteId]/cohorts/CohortEditForm';
+import { useMessages } from '@/components/hooks';
+import { Edit } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import type { Filter } from '@/lib/types';
+
+export function CohortEditButton({
+ cohortId,
+ websiteId,
+ filters,
+}: {
+ cohortId: string;
+ websiteId: string;
+ filters: Filter[];
+}) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogButton
+ icon={<Edit />}
+ variant="quiet"
+ title={formatMessage(labels.cohort)}
+ width="800px"
+ >
+ {({ close }) => {
+ return (
+ <CohortEditForm
+ cohortId={cohortId}
+ websiteId={websiteId}
+ filters={filters}
+ onClose={close}
+ />
+ );
+ }}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortEditForm.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortEditForm.tsx
new file mode 100644
index 0000000..c755035
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortEditForm.tsx
@@ -0,0 +1,135 @@
+import {
+ Button,
+ Column,
+ Form,
+ FormButtons,
+ FormField,
+ FormSubmitButton,
+ Grid,
+ Label,
+ Loading,
+ TextField,
+} from '@umami/react-zen';
+import { useMessages, useUpdateQuery, useWebsiteCohortQuery } from '@/components/hooks';
+import { ActionSelect } from '@/components/input/ActionSelect';
+import { DateFilter } from '@/components/input/DateFilter';
+import { FieldFilters } from '@/components/input/FieldFilters';
+import { LookupField } from '@/components/input/LookupField';
+
+export function CohortEditForm({
+ cohortId,
+ websiteId,
+ filters = [],
+ onSave,
+ onClose,
+}: {
+ cohortId?: string;
+ websiteId: string;
+ filters?: any[];
+ showFilters?: boolean;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { data } = useWebsiteCohortQuery(websiteId, cohortId);
+ const { formatMessage, labels, messages, getErrorMessage } = useMessages();
+
+ const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(
+ `/websites/${websiteId}/segments${cohortId ? `/${cohortId}` : ''}`,
+ {
+ type: 'cohort',
+ },
+ );
+
+ const handleSubmit = async (formData: any) => {
+ await mutateAsync(formData, {
+ onSuccess: async () => {
+ toast(formatMessage(messages.saved));
+ touch('cohorts');
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ if (cohortId && !data) {
+ return <Loading placement="absolute" />;
+ }
+
+ const defaultValues = {
+ parameters: { filters, dateRange: '30day', action: { type: 'path', value: '' } },
+ };
+
+ return (
+ <Form
+ error={getErrorMessage(error)}
+ onSubmit={handleSubmit}
+ defaultValues={data || defaultValues}
+ >
+ {({ watch }) => {
+ const type = watch('parameters.action.type');
+
+ return (
+ <>
+ <FormField
+ name="name"
+ label={formatMessage(labels.name)}
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField autoFocus />
+ </FormField>
+
+ <Column>
+ <Label>{formatMessage(labels.action)}</Label>
+ <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap>
+ <Column>
+ <FormField
+ name="parameters.action.type"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <ActionSelect />
+ </FormField>
+ </Column>
+ <Column>
+ <FormField
+ name="parameters.action.value"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ {({ field }) => {
+ return <LookupField websiteId={websiteId} type={type} {...field} />;
+ }}
+ </FormField>
+ </Column>
+ </Grid>
+ </Column>
+
+ <Column width="260px">
+ <Label>{formatMessage(labels.dateRange)}</Label>
+ <FormField
+ name="parameters.dateRange"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <DateFilter placement="bottom start" />
+ </FormField>
+ </Column>
+
+ <Column>
+ <Label>{formatMessage(labels.filters)}</Label>
+ <FormField name="parameters.filters">
+ <FieldFilters websiteId={websiteId} exclude={['path', 'event']} />
+ </FormField>
+ </Column>
+
+ <FormButtons>
+ <Button isDisabled={isPending} onPress={onClose}>
+ {formatMessage(labels.cancel)}
+ </Button>
+ <FormSubmitButton variant="primary" data-test="button-submit" isDisabled={isPending}>
+ {formatMessage(labels.save)}
+ </FormSubmitButton>
+ </FormButtons>
+ </>
+ );
+ }}
+ </Form>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortsDataTable.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortsDataTable.tsx
new file mode 100644
index 0000000..6734384
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortsDataTable.tsx
@@ -0,0 +1,24 @@
+import { DataGrid } from '@/components/common/DataGrid';
+import { useWebsiteCohortsQuery } from '@/components/hooks';
+import { CohortAddButton } from './CohortAddButton';
+import { CohortsTable } from './CohortsTable';
+
+export function CohortsDataTable({ websiteId }: { websiteId?: string }) {
+ const query = useWebsiteCohortsQuery(websiteId, { type: 'cohort' });
+
+ const renderActions = () => {
+ return <CohortAddButton websiteId={websiteId} />;
+ };
+
+ return (
+ <DataGrid
+ query={query}
+ allowSearch={true}
+ autoFocus={false}
+ allowPaging={true}
+ renderActions={renderActions}
+ >
+ {({ data }) => <CohortsTable data={data} />}
+ </DataGrid>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortsPage.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortsPage.tsx
new file mode 100644
index 0000000..14f366e
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortsPage.tsx
@@ -0,0 +1,16 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { Panel } from '@/components/common/Panel';
+import { CohortsDataTable } from './CohortsDataTable';
+
+export function CohortsPage({ websiteId }) {
+ return (
+ <Column gap="3">
+ <WebsiteControls websiteId={websiteId} allowFilter={false} allowDateFilter={false} />
+ <Panel>
+ <CohortsDataTable websiteId={websiteId} />
+ </Panel>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortsTable.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortsTable.tsx
new file mode 100644
index 0000000..5c7ac03
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortsTable.tsx
@@ -0,0 +1,41 @@
+import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen';
+import Link from 'next/link';
+import { CohortDeleteButton } from '@/app/(main)/websites/[websiteId]/cohorts/CohortDeleteButton';
+import { CohortEditButton } from '@/app/(main)/websites/[websiteId]/cohorts/CohortEditButton';
+import { DateDistance } from '@/components/common/DateDistance';
+import { useMessages, useNavigation } from '@/components/hooks';
+import { filtersObjectToArray } from '@/lib/params';
+
+export function CohortsTable(props: DataTableProps) {
+ const { formatMessage, labels } = useMessages();
+ const { websiteId, renderUrl } = useNavigation();
+
+ return (
+ <DataTable {...props}>
+ <DataColumn id="name" label={formatMessage(labels.name)}>
+ {(row: any) => (
+ <Link href={renderUrl(`/websites/${websiteId}?cohort=${row.id}`, false)}>{row.name}</Link>
+ )}
+ </DataColumn>
+ <DataColumn id="created" label={formatMessage(labels.created)}>
+ {(row: any) => <DateDistance date={new Date(row.createdAt)} />}
+ </DataColumn>
+ <DataColumn id="action" align="end" width="100px">
+ {(row: any) => {
+ const { id, name, parameters } = row;
+
+ return (
+ <Row>
+ <CohortEditButton
+ cohortId={id}
+ websiteId={websiteId}
+ filters={filtersObjectToArray(parameters)}
+ />
+ <CohortDeleteButton cohortId={id} websiteId={websiteId} name={name} />
+ </Row>
+ );
+ }}
+ </DataColumn>
+ </DataTable>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/cohorts/page.tsx b/src/app/(main)/websites/[websiteId]/cohorts/page.tsx
new file mode 100644
index 0000000..9946f60
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/cohorts/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { CohortsPage } from './CohortsPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <CohortsPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Cohorts',
+};
diff --git a/src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx b/src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx
new file mode 100644
index 0000000..bca8d24
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx
@@ -0,0 +1,20 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { WebsiteMetricsBar } from '@/app/(main)/websites/[websiteId]/WebsiteMetricsBar';
+import { Panel } from '@/components/common/Panel';
+import { CompareTables } from './CompareTables';
+
+export function ComparePage({ websiteId }: { websiteId: string }) {
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} allowCompare={true} />
+ <WebsiteMetricsBar websiteId={websiteId} showChange={true} />
+ <Panel minHeight="520px">
+ <WebsiteChart websiteId={websiteId} compareMode={true} />
+ </Panel>
+ <CompareTables websiteId={websiteId} />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx b/src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx
new file mode 100644
index 0000000..13c0516
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx
@@ -0,0 +1,171 @@
+import { Column, Grid, Heading, ListItem, Row, Select } from '@umami/react-zen';
+import { useState } from 'react';
+import { DateDisplay } from '@/components/common/DateDisplay';
+import { Panel } from '@/components/common/Panel';
+import { useDateRange, useMessages, useNavigation } from '@/components/hooks';
+import { ChangeLabel } from '@/components/metrics/ChangeLabel';
+import { MetricsTable } from '@/components/metrics/MetricsTable';
+import { formatNumber } from '@/lib/format';
+
+export function CompareTables({ websiteId }: { websiteId: string }) {
+ const [data, setData] = useState([]);
+ const { dateRange, dateCompare } = useDateRange();
+ const { formatMessage, labels } = useMessages();
+ const {
+ router,
+ updateParams,
+ query: { view = 'path' },
+ } = useNavigation();
+ const { startDate, endDate } = dateCompare;
+
+ const params = {
+ startAt: startDate.getTime(),
+ endAt: endDate.getTime(),
+ };
+
+ const renderPath = (view: string) => {
+ return updateParams({ view });
+ };
+
+ const items = [
+ {
+ id: 'path',
+ label: formatMessage(labels.path),
+ path: renderPath('path'),
+ },
+ {
+ id: 'channel',
+ label: formatMessage(labels.channels),
+ path: renderPath('channel'),
+ },
+ {
+ id: 'referrer',
+ label: formatMessage(labels.referrers),
+ path: renderPath('referrer'),
+ },
+ {
+ id: 'browser',
+ label: formatMessage(labels.browsers),
+ path: renderPath('browser'),
+ },
+ {
+ id: 'os',
+ label: formatMessage(labels.os),
+ path: renderPath('os'),
+ },
+ {
+ id: 'device',
+ label: formatMessage(labels.devices),
+ path: renderPath('device'),
+ },
+ {
+ id: 'country',
+ label: formatMessage(labels.countries),
+ path: renderPath('country'),
+ },
+ {
+ id: 'region',
+ label: formatMessage(labels.regions),
+ path: renderPath('region'),
+ },
+ {
+ id: 'city',
+ label: formatMessage(labels.cities),
+ path: renderPath('city'),
+ },
+ {
+ id: 'language',
+ label: formatMessage(labels.languages),
+ path: renderPath('language'),
+ },
+ {
+ id: 'screen',
+ label: formatMessage(labels.screens),
+ path: renderPath('screen'),
+ },
+ {
+ id: 'event',
+ label: formatMessage(labels.events),
+ path: renderPath('event'),
+ },
+ {
+ id: 'hostname',
+ label: formatMessage(labels.hostname),
+ path: renderPath('hostname'),
+ },
+ {
+ id: 'tag',
+ label: formatMessage(labels.tags),
+ path: renderPath('tag'),
+ },
+ ];
+
+ const renderChange = ({ label, count }) => {
+ const prev = data.find(d => d.x === label)?.y;
+ const value = count - prev;
+ const change = Math.abs(((count - prev) / prev) * 100);
+
+ return (
+ !Number.isNaN(change) && (
+ <Row alignItems="center" marginRight="3">
+ <ChangeLabel value={value}>{formatNumber(change)}%</ChangeLabel>
+ </Row>
+ )
+ );
+ };
+
+ const handleChange = (id: any) => {
+ router.push(renderPath(id));
+ };
+
+ return (
+ <>
+ <Row width="300px">
+ <Select
+ items={items}
+ label={formatMessage(labels.compare)}
+ value={view}
+ defaultValue={view}
+ onChange={handleChange}
+ >
+ {items.map(({ id, label }) => (
+ <ListItem key={id} id={id}>
+ {label}
+ </ListItem>
+ ))}
+ </Select>
+ </Row>
+ <Panel minHeight="300px">
+ <Grid columns={{ xs: '1fr', lg: '1fr 1fr' }} gap="6" height="100%">
+ <Column gap="6">
+ <Row alignItems="center" justifyContent="space-between">
+ <Heading size="2">{formatMessage(labels.previous)}</Heading>
+ <DateDisplay startDate={startDate} endDate={endDate} />
+ </Row>
+ <MetricsTable
+ websiteId={websiteId}
+ type={view}
+ limit={20}
+ showMore={false}
+ params={params}
+ onDataLoad={setData}
+ />
+ </Column>
+ <Column border="left" paddingLeft="6" gap="6">
+ <Row alignItems="center" justifyContent="space-between">
+ <Heading size="2"> {formatMessage(labels.current)}</Heading>
+ <DateDisplay startDate={dateRange.startDate} endDate={dateRange.endDate} />
+ </Row>
+ <MetricsTable
+ websiteId={websiteId}
+ type={view}
+ limit={20}
+ showMore={false}
+ renderChange={renderChange}
+ />
+ </Column>
+ </Grid>
+ </Panel>
+ </>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/compare/page.tsx b/src/app/(main)/websites/[websiteId]/compare/page.tsx
new file mode 100644
index 0000000..1b2899b
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/compare/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { ComparePage } from './ComparePage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <ComparePage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Compare',
+};
diff --git a/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx b/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx
new file mode 100644
index 0000000..c3b1325
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx
@@ -0,0 +1,127 @@
+import { Column, Grid, ListItem, Select } from '@umami/react-zen';
+import { useMemo, useState } from 'react';
+import { PieChart } from '@/components/charts/PieChart';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import {
+ useEventDataPropertiesQuery,
+ useEventDataValuesQuery,
+ useMessages,
+} from '@/components/hooks';
+import { ListTable } from '@/components/metrics/ListTable';
+import { CHART_COLORS } from '@/lib/constants';
+
+export function EventProperties({ websiteId }: { websiteId: string }) {
+ const [propertyName, setPropertyName] = useState('');
+ const [eventName, setEventName] = useState('');
+
+ const { formatMessage, labels } = useMessages();
+ const { data, isLoading, isFetching, error } = useEventDataPropertiesQuery(websiteId);
+
+ const events: string[] = data
+ ? data.reduce((arr: string | any[], e: { eventName: any }) => {
+ return !arr.includes(e.eventName) ? arr.concat(e.eventName) : arr;
+ }, [])
+ : [];
+ const properties: string[] = eventName
+ ? data?.filter(e => e.eventName === eventName).map(e => e.propertyName)
+ : [];
+
+ return (
+ <LoadingPanel
+ data={data}
+ isLoading={isLoading}
+ isFetching={isFetching}
+ error={error}
+ minHeight="300px"
+ >
+ <Column gap="6">
+ {data && (
+ <Grid columns="repeat(auto-fill, minmax(300px, 1fr))" marginBottom="3" gap>
+ <Select
+ label={formatMessage(labels.event)}
+ value={eventName}
+ onChange={setEventName}
+ placeholder=""
+ >
+ {events?.map(p => (
+ <ListItem key={p} id={p}>
+ {p}
+ </ListItem>
+ ))}
+ </Select>
+ <Select
+ label={formatMessage(labels.property)}
+ value={propertyName}
+ onChange={setPropertyName}
+ isDisabled={!eventName}
+ placeholder=""
+ >
+ {properties?.map(p => (
+ <ListItem key={p} id={p}>
+ {p}
+ </ListItem>
+ ))}
+ </Select>
+ </Grid>
+ )}
+ {eventName && propertyName && (
+ <EventValues websiteId={websiteId} eventName={eventName} propertyName={propertyName} />
+ )}
+ </Column>
+ </LoadingPanel>
+ );
+}
+
+const EventValues = ({ websiteId, eventName, propertyName }) => {
+ const {
+ data: values,
+ isLoading,
+ isFetching,
+ error,
+ } = useEventDataValuesQuery(websiteId, eventName, propertyName);
+
+ const propertySum = useMemo(() => {
+ return values?.reduce((sum, { total }) => sum + total, 0) ?? 0;
+ }, [values]);
+
+ const chartData = useMemo(() => {
+ if (!propertyName || !values) return null;
+ return {
+ labels: values.map(({ value }) => value),
+ datasets: [
+ {
+ data: values.map(({ total }) => total),
+ backgroundColor: CHART_COLORS,
+ borderWidth: 0,
+ },
+ ],
+ };
+ }, [propertyName, values]);
+
+ const tableData = useMemo(() => {
+ if (!propertyName || !values || propertySum === 0) return [];
+ return values.map(({ value, total }) => ({
+ label: value,
+ count: total,
+ percent: 100 * (total / propertySum),
+ }));
+ }, [propertyName, values, propertySum]);
+
+ return (
+ <LoadingPanel
+ isLoading={isLoading}
+ isFetching={isFetching}
+ data={values}
+ error={error}
+ minHeight="300px"
+ gap="6"
+ >
+ {values && (
+ <Grid columns="1fr 1fr" gap>
+ <ListTable title={propertyName} data={tableData} />
+ <PieChart type="doughnut" chartData={chartData} />
+ </Grid>
+ )}
+ </LoadingPanel>
+ );
+};
diff --git a/src/app/(main)/websites/[websiteId]/events/EventsDataTable.tsx b/src/app/(main)/websites/[websiteId]/events/EventsDataTable.tsx
new file mode 100644
index 0000000..f686b3f
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/events/EventsDataTable.tsx
@@ -0,0 +1,48 @@
+import { type ReactNode, useState } from 'react';
+import { DataGrid } from '@/components/common/DataGrid';
+import { useMessages, useWebsiteEventsQuery } from '@/components/hooks';
+import { FilterButtons } from '@/components/input/FilterButtons';
+import { EventsTable } from './EventsTable';
+
+export function EventsDataTable({
+ websiteId,
+}: {
+ websiteId?: string;
+ teamId?: string;
+ children?: ReactNode;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const [view, setView] = useState('all');
+ const query = useWebsiteEventsQuery(websiteId, { view });
+
+ const buttons = [
+ {
+ id: 'all',
+ label: formatMessage(labels.all),
+ },
+ {
+ id: 'views',
+ label: formatMessage(labels.views),
+ },
+ {
+ id: 'events',
+ label: formatMessage(labels.events),
+ },
+ ];
+
+ const renderActions = () => {
+ return <FilterButtons items={buttons} value={view} onChange={setView} />;
+ };
+
+ return (
+ <DataGrid
+ query={query}
+ allowSearch={true}
+ autoFocus={false}
+ allowPaging={true}
+ renderActions={renderActions}
+ >
+ {({ data }) => <EventsTable data={data} />}
+ </DataGrid>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx
new file mode 100644
index 0000000..a7ed399
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx
@@ -0,0 +1,40 @@
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useMessages } from '@/components/hooks';
+import { useWebsiteSessionStatsQuery } from '@/components/hooks/queries/useWebsiteSessionStatsQuery';
+import { MetricCard } from '@/components/metrics/MetricCard';
+import { MetricsBar } from '@/components/metrics/MetricsBar';
+import { formatLongNumber } from '@/lib/format';
+
+export function EventsMetricsBar({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+ const { data, isLoading, isFetching, error } = useWebsiteSessionStatsQuery(websiteId);
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
+ {data && (
+ <MetricsBar>
+ <MetricCard
+ value={data?.visitors?.value}
+ label={formatMessage(labels.visitors)}
+ formatValue={formatLongNumber}
+ />
+ <MetricCard
+ value={data?.visits?.value}
+ label={formatMessage(labels.visits)}
+ formatValue={formatLongNumber}
+ />
+ <MetricCard
+ value={data?.pageviews?.value}
+ label={formatMessage(labels.views)}
+ formatValue={formatLongNumber}
+ />
+ <MetricCard
+ value={data?.events?.value}
+ label={formatMessage(labels.events)}
+ formatValue={formatLongNumber}
+ />
+ </MetricsBar>
+ )}
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx b/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx
new file mode 100644
index 0000000..55ec040
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx
@@ -0,0 +1,59 @@
+'use client';
+import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
+import { type Key, useState } from 'react';
+import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { Panel } from '@/components/common/Panel';
+import { useMessages } from '@/components/hooks';
+import { EventsChart } from '@/components/metrics/EventsChart';
+import { MetricsTable } from '@/components/metrics/MetricsTable';
+import { getItem, setItem } from '@/lib/storage';
+import { EventProperties } from './EventProperties';
+import { EventsDataTable } from './EventsDataTable';
+
+const KEY_NAME = 'umami.events.tab';
+
+export function EventsPage({ websiteId }) {
+ const [tab, setTab] = useState(getItem(KEY_NAME) || 'chart');
+ const { formatMessage, labels } = useMessages();
+
+ const handleSelect = (value: Key) => {
+ setItem(KEY_NAME, value);
+ setTab(value);
+ };
+
+ return (
+ <Column gap="3">
+ <WebsiteControls websiteId={websiteId} />
+ <Panel>
+ <Tabs selectedKey={tab} onSelectionChange={key => handleSelect(key)}>
+ <TabList>
+ <Tab id="chart">{formatMessage(labels.chart)}</Tab>
+ <Tab id="activity">{formatMessage(labels.activity)}</Tab>
+ <Tab id="properties">{formatMessage(labels.properties)}</Tab>
+ </TabList>
+ <TabPanel id="activity">
+ <EventsDataTable websiteId={websiteId} />
+ </TabPanel>
+ <TabPanel id="chart">
+ <Column gap="6">
+ <Column border="bottom" paddingBottom="6">
+ <EventsChart websiteId={websiteId} />
+ </Column>
+ <MetricsTable
+ websiteId={websiteId}
+ type="event"
+ title={formatMessage(labels.event)}
+ metric={formatMessage(labels.count)}
+ />
+ </Column>
+ </TabPanel>
+ <TabPanel id="properties">
+ <EventProperties websiteId={websiteId} />
+ </TabPanel>
+ </Tabs>
+ </Panel>
+ <SessionModal websiteId={websiteId} />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx b/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx
new file mode 100644
index 0000000..7fb2eb4
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx
@@ -0,0 +1,107 @@
+import {
+ Button,
+ DataColumn,
+ DataTable,
+ type DataTableProps,
+ Dialog,
+ DialogTrigger,
+ Icon,
+ IconLabel,
+ Popover,
+ Row,
+ Text,
+} from '@umami/react-zen';
+import Link from 'next/link';
+import { Avatar } from '@/components/common/Avatar';
+import { DateDistance } from '@/components/common/DateDistance';
+import { TypeIcon } from '@/components/common/TypeIcon';
+import { useFormat, useMessages, useNavigation } from '@/components/hooks';
+import { Eye, FileText } from '@/components/icons';
+import { EventData } from '@/components/metrics/EventData';
+import { Lightning } from '@/components/svg';
+
+export function EventsTable(props: DataTableProps) {
+ const { formatMessage, labels } = useMessages();
+ const { updateParams } = useNavigation();
+ const { formatValue } = useFormat();
+
+ return (
+ <DataTable {...props}>
+ <DataColumn id="event" label={formatMessage(labels.event)} width="2fr">
+ {(row: any) => {
+ return (
+ <Row alignItems="center" wrap="wrap" gap>
+ <Row>
+ <IconLabel
+ icon={row.eventName ? <Lightning /> : <Eye />}
+ label={formatMessage(row.eventName ? labels.triggeredEvent : labels.viewedPage)}
+ />
+ </Row>
+ <Text
+ weight="bold"
+ style={{ maxWidth: '300px' }}
+ title={row.eventName || row.urlPath}
+ truncate
+ >
+ {row.eventName || row.urlPath}
+ </Text>
+ {row.hasData > 0 && <PropertiesButton websiteId={row.websiteId} eventId={row.id} />}
+ </Row>
+ );
+ }}
+ </DataColumn>
+ <DataColumn id="session" label={formatMessage(labels.session)} width="80px">
+ {(row: any) => {
+ return (
+ <Link href={updateParams({ session: row.sessionId })}>
+ <Avatar seed={row.sessionId} size={32} />
+ </Link>
+ );
+ }}
+ </DataColumn>
+ <DataColumn id="location" label={formatMessage(labels.location)}>
+ {(row: any) => (
+ <TypeIcon type="country" value={row.country}>
+ {row.city ? `${row.city}, ` : ''} {formatValue(row.country, 'country')}
+ </TypeIcon>
+ )}
+ </DataColumn>
+ <DataColumn id="browser" label={formatMessage(labels.browser)} width="140px">
+ {(row: any) => (
+ <TypeIcon type="browser" value={row.browser}>
+ {formatValue(row.browser, 'browser')}
+ </TypeIcon>
+ )}
+ </DataColumn>
+ <DataColumn id="device" label={formatMessage(labels.device)} width="120px">
+ {(row: any) => (
+ <TypeIcon type="device" value={row.device}>
+ {formatValue(row.device, 'device')}
+ </TypeIcon>
+ )}
+ </DataColumn>
+ <DataColumn id="created" width="160px" align="end">
+ {(row: any) => <DateDistance date={new Date(row.createdAt)} />}
+ </DataColumn>
+ </DataTable>
+ );
+}
+
+const PropertiesButton = props => {
+ return (
+ <DialogTrigger>
+ <Button variant="quiet">
+ <Row alignItems="center" gap>
+ <Icon>
+ <FileText />
+ </Icon>
+ </Row>
+ </Button>
+ <Popover placement="right">
+ <Dialog>
+ <EventData {...props} />
+ </Dialog>
+ </Popover>
+ </DialogTrigger>
+ );
+};
diff --git a/src/app/(main)/websites/[websiteId]/events/page.tsx b/src/app/(main)/websites/[websiteId]/events/page.tsx
new file mode 100644
index 0000000..d77ba3b
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/events/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { EventsPage } from './EventsPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <EventsPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Events',
+};
diff --git a/src/app/(main)/websites/[websiteId]/layout.tsx b/src/app/(main)/websites/[websiteId]/layout.tsx
new file mode 100644
index 0000000..67595e9
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/layout.tsx
@@ -0,0 +1,21 @@
+import type { Metadata } from 'next';
+import { WebsiteLayout } from '@/app/(main)/websites/[websiteId]/WebsiteLayout';
+
+export default async function ({
+ children,
+ params,
+}: {
+ children: any;
+ params: Promise<{ websiteId: string }>;
+}) {
+ const { websiteId } = await params;
+
+ return <WebsiteLayout websiteId={websiteId}>{children}</WebsiteLayout>;
+}
+
+export const metadata: Metadata = {
+ title: {
+ template: '%s | Umami',
+ default: 'Websites | Umami',
+ },
+};
diff --git a/src/app/(main)/websites/[websiteId]/page.tsx b/src/app/(main)/websites/[websiteId]/page.tsx
new file mode 100644
index 0000000..d4889c5
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { WebsitePage } from './WebsitePage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <WebsitePage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Websites',
+};
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.tsx
new file mode 100644
index 0000000..6e2495b
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.tsx
@@ -0,0 +1,31 @@
+import { IconLabel } from '@umami/react-zen';
+import { useCallback } from 'react';
+import { TypeIcon } from '@/components/common/TypeIcon';
+import { useCountryNames, useLocale, useMessages } from '@/components/hooks';
+import { ListTable } from '@/components/metrics/ListTable';
+
+export function RealtimeCountries({ data }) {
+ const { formatMessage, labels } = useMessages();
+ const { locale } = useLocale();
+ const { countryNames } = useCountryNames(locale);
+
+ const renderCountryName = useCallback(
+ ({ label: code }) => (
+ <IconLabel icon={<TypeIcon type="country" value={code} />} label={countryNames[code]} />
+ ),
+ [countryNames, locale],
+ );
+
+ return (
+ <ListTable
+ title={formatMessage(labels.countries)}
+ metric={formatMessage(labels.visitors)}
+ data={data.map(({ x, y, z }: { x: string; y: number; z: number }) => ({
+ label: x,
+ count: y,
+ percent: z,
+ }))}
+ renderLabel={renderCountryName}
+ />
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx
new file mode 100644
index 0000000..2b9d881
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx
@@ -0,0 +1,17 @@
+import { useMessages } from '@/components/hooks';
+import { MetricCard } from '@/components/metrics/MetricCard';
+import { MetricsBar } from '@/components/metrics/MetricsBar';
+
+export function RealtimeHeader({ data }: { data: any }) {
+ const { formatMessage, labels } = useMessages();
+ const { totals }: any = data || {};
+
+ return (
+ <MetricsBar>
+ <MetricCard label={formatMessage(labels.views)} value={totals.views} />
+ <MetricCard label={formatMessage(labels.visitors)} value={totals.visitors} />
+ <MetricCard label={formatMessage(labels.events)} value={totals.events} />
+ <MetricCard label={formatMessage(labels.countries)} value={totals.countries} />
+ </MetricsBar>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx
new file mode 100644
index 0000000..1076361
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx
@@ -0,0 +1,206 @@
+import { Column, Heading, IconLabel, Row, SearchField, Text } from '@umami/react-zen';
+import Link from 'next/link';
+import { useMemo, useState } from 'react';
+import { FixedSizeList } from 'react-window';
+import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal';
+import { useFormat } from '@/components//hooks/useFormat';
+import { Avatar } from '@/components/common/Avatar';
+import { Empty } from '@/components/common/Empty';
+import {
+ useCountryNames,
+ useLocale,
+ useMessages,
+ useMobile,
+ useNavigation,
+ useTimezone,
+ useWebsite,
+} from '@/components/hooks';
+import { Eye, User } from '@/components/icons';
+import { FilterButtons } from '@/components/input/FilterButtons';
+import { Lightning } from '@/components/svg';
+import { BROWSERS, OS_NAMES } from '@/lib/constants';
+
+const TYPE_ALL = 'all';
+const TYPE_PAGEVIEW = 'pageview';
+const TYPE_SESSION = 'session';
+const TYPE_EVENT = 'event';
+
+const icons = {
+ [TYPE_PAGEVIEW]: <Eye />,
+ [TYPE_SESSION]: <User />,
+ [TYPE_EVENT]: <Lightning />,
+};
+
+export function RealtimeLog({ data }: { data: any }) {
+ const website = useWebsite();
+ const [search, setSearch] = useState('');
+ const { formatMessage, labels, messages, FormattedMessage } = useMessages();
+ const { formatValue } = useFormat();
+ const { locale } = useLocale();
+ const { formatTimezoneDate } = useTimezone();
+ const { countryNames } = useCountryNames(locale);
+ const [filter, setFilter] = useState(TYPE_ALL);
+ const { updateParams } = useNavigation();
+ const { isPhone } = useMobile();
+
+ const buttons = [
+ {
+ label: formatMessage(labels.all),
+ id: TYPE_ALL,
+ },
+ {
+ label: formatMessage(labels.views),
+ id: TYPE_PAGEVIEW,
+ },
+ {
+ label: formatMessage(labels.visitors),
+ id: TYPE_SESSION,
+ },
+ {
+ label: formatMessage(labels.events),
+ id: TYPE_EVENT,
+ },
+ ];
+
+ const getTime = ({ createdAt, firstAt }) => formatTimezoneDate(firstAt || createdAt, 'pp');
+
+ const getIcon = ({ __type }) => icons[__type];
+
+ const getDetail = (log: {
+ __type: string;
+ eventName: string;
+ urlPath: string;
+ browser: string;
+ os: string;
+ country: string;
+ device: string;
+ }) => {
+ const { __type, eventName, urlPath, browser, os, country, device } = log;
+
+ if (__type === TYPE_EVENT) {
+ return (
+ <FormattedMessage
+ {...messages.eventLog}
+ values={{
+ event: <b key="b">{eventName || formatMessage(labels.unknown)}</b>,
+ url: (
+ <a
+ key="a"
+ href={`//${website?.domain}${urlPath}`}
+ target="_blank"
+ rel="noreferrer noopener"
+ >
+ {urlPath}
+ </a>
+ ),
+ }}
+ />
+ );
+ }
+
+ if (__type === TYPE_PAGEVIEW) {
+ return (
+ <a href={`//${website?.domain}${urlPath}`} target="_blank" rel="noreferrer noopener">
+ {urlPath}
+ </a>
+ );
+ }
+
+ if (__type === TYPE_SESSION) {
+ return (
+ <FormattedMessage
+ {...messages.visitorLog}
+ values={{
+ country: <b key="country">{countryNames[country] || formatMessage(labels.unknown)}</b>,
+ browser: <b key="browser">{BROWSERS[browser]}</b>,
+ os: <b key="os">{OS_NAMES[os] || os}</b>,
+ device: <b key="device">{formatMessage(labels[device] || labels.unknown)}</b>,
+ }}
+ />
+ );
+ }
+ };
+
+ const TableRow = ({ index, style }) => {
+ const row = logs[index];
+ return (
+ <Row alignItems="center" style={style} gap>
+ <Row minWidth="30px">
+ <Link href={updateParams({ session: row.sessionId })}>
+ <Avatar seed={row.sessionId} size={32} />
+ </Link>
+ </Row>
+ <Row minWidth="100px">
+ <Text wrap="nowrap">{getTime(row)}</Text>
+ </Row>
+ <IconLabel icon={getIcon(row)}>
+ <Text style={{ maxWidth: isPhone ? '400px' : null }} truncate>
+ {getDetail(row)}
+ </Text>
+ </IconLabel>
+ </Row>
+ );
+ };
+
+ const logs = useMemo(() => {
+ if (!data) {
+ return [];
+ }
+
+ let logs = data.events;
+
+ if (search) {
+ logs = logs.filter(({ eventName, urlPath, browser, os, country, device }) => {
+ return [
+ eventName,
+ urlPath,
+ os,
+ formatValue(browser, 'browser'),
+ formatValue(country, 'country'),
+ formatValue(device, 'device'),
+ ]
+ .filter(n => n)
+ .map(n => n.toLowerCase())
+ .join('')
+ .includes(search.toLowerCase());
+ });
+ }
+
+ if (filter !== TYPE_ALL) {
+ return logs.filter(({ __type }) => __type === filter);
+ }
+
+ return logs;
+ }, [data, filter, formatValue, search]);
+
+ return (
+ <Column gap>
+ <Heading size="2">{formatMessage(labels.activity)}</Heading>
+ {isPhone ? (
+ <>
+ <Row>
+ <SearchField value={search} onSearch={setSearch} />
+ </Row>
+ <Row>
+ <FilterButtons items={buttons} value={filter} onChange={setFilter} />
+ </Row>
+ </>
+ ) : (
+ <Row alignItems="center" justifyContent="space-between">
+ <SearchField value={search} onSearch={setSearch} />
+ <FilterButtons items={buttons} value={filter} onChange={setFilter} />
+ </Row>
+ )}
+
+ <Column>
+ {logs?.length === 0 && <Empty />}
+ {logs?.length > 0 && (
+ <FixedSizeList width="100%" height={500} itemCount={logs.length} itemSize={50}>
+ {TableRow}
+ </FixedSizeList>
+ )}
+ </Column>
+ <SessionModal websiteId={website.id} />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx
new file mode 100644
index 0000000..6220c69
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx
@@ -0,0 +1,58 @@
+'use client';
+import { Grid } from '@umami/react-zen';
+import { firstBy } from 'thenby';
+import { GridRow } from '@/components/common/GridRow';
+import { PageBody } from '@/components/common/PageBody';
+import { Panel } from '@/components/common/Panel';
+import { useMobile, useRealtimeQuery } from '@/components/hooks';
+import { RealtimeChart } from '@/components/metrics/RealtimeChart';
+import { WorldMap } from '@/components/metrics/WorldMap';
+import { percentFilter } from '@/lib/filters';
+import { RealtimeCountries } from './RealtimeCountries';
+import { RealtimeHeader } from './RealtimeHeader';
+import { RealtimeLog } from './RealtimeLog';
+import { RealtimePaths } from './RealtimePaths';
+import { RealtimeReferrers } from './RealtimeReferrers';
+
+export function RealtimePage({ websiteId }: { websiteId: string }) {
+ const { data, isLoading, error } = useRealtimeQuery(websiteId);
+ const { isMobile } = useMobile();
+
+ if (isLoading || error) {
+ return <PageBody isLoading={isLoading} error={error} />;
+ }
+
+ const countries = percentFilter(
+ Object.keys(data.countries)
+ .map(key => ({ x: key, y: data.countries[key] }))
+ .sort(firstBy('y', -1)),
+ );
+
+ return (
+ <Grid gap="3">
+ <RealtimeHeader data={data} />
+ <Panel>
+ <RealtimeChart data={data} unit="minute" />
+ </Panel>
+ <Panel>
+ <RealtimeLog data={data} />
+ </Panel>
+ <GridRow layout="two">
+ <Panel>
+ <RealtimePaths data={data} />
+ </Panel>
+ <Panel>
+ <RealtimeReferrers data={data} />
+ </Panel>
+ </GridRow>
+ <GridRow layout="one-two">
+ <Panel>
+ <RealtimeCountries data={countries} />
+ </Panel>
+ <Panel gridColumn={isMobile ? null : 'span 2'} padding="0">
+ <WorldMap data={countries} />
+ </Panel>
+ </GridRow>
+ </Grid>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimePaths.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimePaths.tsx
new file mode 100644
index 0000000..1f90ad8
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimePaths.tsx
@@ -0,0 +1,45 @@
+import thenby from 'thenby';
+import { useMessages, useWebsite } from '@/components/hooks';
+import { ListTable } from '@/components/metrics/ListTable';
+import { percentFilter } from '@/lib/filters';
+
+export function RealtimePaths({ data }: { data: any }) {
+ const website = useWebsite();
+ const { formatMessage, labels } = useMessages();
+ const { urls } = data || {};
+ const limit = 15;
+
+ const renderLink = ({ label: x }) => {
+ const domain = x.startsWith('/') ? website?.domain : '';
+ return (
+ <a href={`//${domain}${x}`} target="_blank" rel="noreferrer noopener">
+ {x}
+ </a>
+ );
+ };
+
+ const pages = percentFilter(
+ Object.keys(urls)
+ .map(key => {
+ return {
+ x: key,
+ y: urls[key],
+ };
+ })
+ .sort(thenby.firstBy('y', -1))
+ .slice(0, limit),
+ );
+
+ return (
+ <ListTable
+ title={formatMessage(labels.pages)}
+ metric={formatMessage(labels.views)}
+ renderLabel={renderLink}
+ data={pages.map(({ x, y, z }: { x: string; y: number; z: number }) => ({
+ label: x,
+ count: y,
+ percent: z,
+ }))}
+ />
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeReferrers.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeReferrers.tsx
new file mode 100644
index 0000000..9fd4477
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeReferrers.tsx
@@ -0,0 +1,45 @@
+import thenby from 'thenby';
+import { useMessages, useWebsite } from '@/components/hooks';
+import { ListTable } from '@/components/metrics/ListTable';
+import { percentFilter } from '@/lib/filters';
+
+export function RealtimeReferrers({ data }: { data: any }) {
+ const website = useWebsite();
+ const { formatMessage, labels } = useMessages();
+ const { referrers } = data || {};
+ const limit = 15;
+
+ const renderLink = ({ label: x }) => {
+ const domain = x.startsWith('/') ? website?.domain : '';
+ return (
+ <a href={`//${domain}${x}`} target="_blank" rel="noreferrer noopener">
+ {x}
+ </a>
+ );
+ };
+
+ const domains = percentFilter(
+ Object.keys(referrers)
+ .map(key => {
+ return {
+ x: key,
+ y: referrers[key],
+ };
+ })
+ .sort(thenby.firstBy('y', -1))
+ .slice(0, limit),
+ );
+
+ return (
+ <ListTable
+ title={formatMessage(labels.referrers)}
+ metric={formatMessage(labels.views)}
+ renderLabel={renderLink}
+ data={domains.map(({ x, y, z }: { x: string; y: number; z: number }) => ({
+ label: x,
+ count: y,
+ percent: z,
+ }))}
+ />
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/page.tsx b/src/app/(main)/websites/[websiteId]/realtime/page.tsx
new file mode 100644
index 0000000..1552196
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/realtime/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { RealtimePage } from './RealtimePage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <RealtimePage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Real-time',
+};
diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentAddButton.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentAddButton.tsx
new file mode 100644
index 0000000..7b70fee
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/segments/SegmentAddButton.tsx
@@ -0,0 +1,21 @@
+import { useMessages } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { SegmentEditForm } from './SegmentEditForm';
+
+export function SegmentAddButton({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogButton
+ icon={<Plus />}
+ label={formatMessage(labels.segment)}
+ variant="primary"
+ width="800px"
+ >
+ {({ close }) => {
+ return <SegmentEditForm websiteId={websiteId} onClose={close} />;
+ }}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentDeleteButton.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentDeleteButton.tsx
new file mode 100644
index 0000000..bb52a22
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/segments/SegmentDeleteButton.tsx
@@ -0,0 +1,60 @@
+import { ConfirmationForm } from '@/components/common/ConfirmationForm';
+import { useDeleteQuery, useMessages } from '@/components/hooks';
+import { Trash } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { messages } from '@/components/messages';
+
+export function SegmentDeleteButton({
+ segmentId,
+ websiteId,
+ name,
+ onSave,
+}: {
+ segmentId: string;
+ websiteId: string;
+ name: string;
+ onSave?: () => void;
+}) {
+ const { formatMessage, labels, FormattedMessage } = useMessages();
+ const { mutateAsync, isPending, error, touch } = useDeleteQuery(
+ `/websites/${websiteId}/segments/${segmentId}`,
+ );
+
+ const handleConfirm = async (close: () => void) => {
+ await mutateAsync(null, {
+ onSuccess: () => {
+ touch('segments');
+ onSave?.();
+ close();
+ },
+ });
+ };
+
+ return (
+ <DialogButton
+ icon={<Trash />}
+ title={formatMessage(labels.confirm)}
+ variant="quiet"
+ width="600px"
+ >
+ {({ close }) => (
+ <ConfirmationForm
+ message={
+ <FormattedMessage
+ {...messages.confirmRemove}
+ values={{
+ target: <b>{name}</b>,
+ }}
+ />
+ }
+ isLoading={isPending}
+ error={error}
+ onConfirm={handleConfirm.bind(null, close)}
+ onClose={close}
+ buttonLabel={formatMessage(labels.delete)}
+ buttonVariant="danger"
+ />
+ )}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentEditButton.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentEditButton.tsx
new file mode 100644
index 0000000..5c56cf1
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/segments/SegmentEditButton.tsx
@@ -0,0 +1,37 @@
+import { useMessages } from '@/components/hooks';
+import { Edit } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import type { Filter } from '@/lib/types';
+import { SegmentEditForm } from './SegmentEditForm';
+
+export function SegmentEditButton({
+ segmentId,
+ websiteId,
+ filters,
+}: {
+ segmentId: string;
+ websiteId: string;
+ filters?: Filter[];
+}) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogButton
+ icon={<Edit />}
+ title={formatMessage(labels.segment)}
+ variant="quiet"
+ width="800px"
+ >
+ {({ close }) => {
+ return (
+ <SegmentEditForm
+ segmentId={segmentId}
+ websiteId={websiteId}
+ filters={filters}
+ onClose={close}
+ />
+ );
+ }}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentEditForm.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentEditForm.tsx
new file mode 100644
index 0000000..c3529d9
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/segments/SegmentEditForm.tsx
@@ -0,0 +1,86 @@
+import {
+ Button,
+ Form,
+ FormButtons,
+ FormField,
+ FormSubmitButton,
+ Label,
+ Loading,
+ TextField,
+} from '@umami/react-zen';
+import { useMessages, useUpdateQuery, useWebsiteSegmentQuery } from '@/components/hooks';
+import { FieldFilters } from '@/components/input/FieldFilters';
+import { messages } from '@/components/messages';
+
+export function SegmentEditForm({
+ segmentId,
+ websiteId,
+ filters = [],
+ showFilters = true,
+ onSave,
+ onClose,
+}: {
+ segmentId?: string;
+ websiteId: string;
+ filters?: any[];
+ showFilters?: boolean;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { data } = useWebsiteSegmentQuery(websiteId, segmentId);
+ const { formatMessage, labels, getErrorMessage } = useMessages();
+
+ const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(
+ `/websites/${websiteId}/segments${segmentId ? `/${segmentId}` : ''}`,
+ {
+ type: 'segment',
+ },
+ );
+
+ const handleSubmit = async (formData: any) => {
+ await mutateAsync(formData, {
+ onSuccess: async () => {
+ toast(formatMessage(messages.saved));
+ touch('segments');
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ if (segmentId && !data) {
+ return <Loading placement="absolute" />;
+ }
+
+ return (
+ <Form
+ onSubmit={handleSubmit}
+ defaultValues={data || { parameters: { filters } }}
+ error={getErrorMessage(error)}
+ >
+ <FormField
+ name="name"
+ label={formatMessage(labels.name)}
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField autoFocus={!segmentId} />
+ </FormField>
+ {showFilters && (
+ <>
+ <Label>{formatMessage(labels.filters)}</Label>
+ <FormField name="parameters.filters" rules={{ required: formatMessage(labels.required) }}>
+ <FieldFilters websiteId={websiteId} />
+ </FormField>
+ </>
+ )}
+ <FormButtons>
+ <Button isDisabled={isPending} onPress={onClose}>
+ {formatMessage(labels.cancel)}
+ </Button>
+ <FormSubmitButton variant="primary" data-test="button-submit" isDisabled={isPending}>
+ {formatMessage(labels.save)}
+ </FormSubmitButton>
+ </FormButtons>
+ </Form>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentsDataTable.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentsDataTable.tsx
new file mode 100644
index 0000000..c1ba82e
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/segments/SegmentsDataTable.tsx
@@ -0,0 +1,24 @@
+import { DataGrid } from '@/components/common/DataGrid';
+import { useWebsiteSegmentsQuery } from '@/components/hooks';
+import { SegmentAddButton } from './SegmentAddButton';
+import { SegmentsTable } from './SegmentsTable';
+
+export function SegmentsDataTable({ websiteId }: { websiteId?: string }) {
+ const query = useWebsiteSegmentsQuery(websiteId, { type: 'segment' });
+
+ const renderActions = () => {
+ return <SegmentAddButton websiteId={websiteId} />;
+ };
+
+ return (
+ <DataGrid
+ query={query}
+ allowSearch={true}
+ autoFocus={false}
+ allowPaging={true}
+ renderActions={renderActions}
+ >
+ {({ data }) => <SegmentsTable data={data} />}
+ </DataGrid>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentsPage.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentsPage.tsx
new file mode 100644
index 0000000..cbe7a1c
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/segments/SegmentsPage.tsx
@@ -0,0 +1,16 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { Panel } from '@/components/common/Panel';
+import { SegmentsDataTable } from './SegmentsDataTable';
+
+export function SegmentsPage({ websiteId }) {
+ return (
+ <Column gap="3">
+ <WebsiteControls websiteId={websiteId} allowFilter={false} allowDateFilter={false} />
+ <Panel>
+ <SegmentsDataTable websiteId={websiteId} />
+ </Panel>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentsTable.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentsTable.tsx
new file mode 100644
index 0000000..4dbe511
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/segments/SegmentsTable.tsx
@@ -0,0 +1,38 @@
+import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen';
+import Link from 'next/link';
+import { SegmentDeleteButton } from '@/app/(main)/websites/[websiteId]/segments/SegmentDeleteButton';
+import { SegmentEditButton } from '@/app/(main)/websites/[websiteId]/segments/SegmentEditButton';
+import { DateDistance } from '@/components/common/DateDistance';
+import { useMessages, useNavigation } from '@/components/hooks';
+
+export function SegmentsTable(props: DataTableProps) {
+ const { formatMessage, labels } = useMessages();
+ const { websiteId, renderUrl } = useNavigation();
+
+ return (
+ <DataTable {...props}>
+ <DataColumn id="name" label={formatMessage(labels.name)}>
+ {(row: any) => (
+ <Link href={renderUrl(`/websites/${websiteId}?segment=${row.id}`, false)}>
+ {row.name}
+ </Link>
+ )}
+ </DataColumn>
+ <DataColumn id="created" label={formatMessage(labels.created)}>
+ {(row: any) => <DateDistance date={new Date(row.createdAt)} />}
+ </DataColumn>
+ <DataColumn id="action" align="end" width="100px">
+ {(row: any) => {
+ const { id, name } = row;
+
+ return (
+ <Row>
+ <SegmentEditButton segmentId={id} websiteId={websiteId} />
+ <SegmentDeleteButton segmentId={id} websiteId={websiteId} name={name} />
+ </Row>
+ );
+ }}
+ </DataColumn>
+ </DataTable>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/segments/page.tsx b/src/app/(main)/websites/[websiteId]/segments/page.tsx
new file mode 100644
index 0000000..0d3faac
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/segments/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { SegmentsPage } from './SegmentsPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <SegmentsPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Segments',
+};
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx
new file mode 100644
index 0000000..cbb2810
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx
@@ -0,0 +1,94 @@
+import {
+ Button,
+ Column,
+ Dialog,
+ DialogTrigger,
+ Heading,
+ Icon,
+ Popover,
+ Row,
+ StatusLight,
+ Text,
+} from '@umami/react-zen';
+import { isSameDay } from 'date-fns';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useMessages, useMobile, useSessionActivityQuery, useTimezone } from '@/components/hooks';
+import { Eye, FileText } from '@/components/icons';
+import { EventData } from '@/components/metrics/EventData';
+import { Lightning } from '@/components/svg';
+
+export function SessionActivity({
+ websiteId,
+ sessionId,
+ startDate,
+ endDate,
+}: {
+ websiteId: string;
+ sessionId: string;
+ startDate: Date;
+ endDate: Date;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { formatTimezoneDate } = useTimezone();
+ const { data, isLoading, error } = useSessionActivityQuery(
+ websiteId,
+ sessionId,
+ startDate,
+ endDate,
+ );
+ const { isMobile } = useMobile();
+ let lastDay = null;
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ <Column gap>
+ {data?.map(({ eventId, createdAt, urlPath, eventName, visitId, hasData }) => {
+ const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt));
+ lastDay = createdAt;
+
+ return (
+ <Column key={eventId} gap>
+ {showHeader && <Heading size="1">{formatTimezoneDate(createdAt, 'PPPP')}</Heading>}
+ <Row alignItems="center" gap="6" height="40px">
+ <StatusLight color={`#${visitId?.substring(0, 6)}`}>
+ <Text wrap="nowrap">{formatTimezoneDate(createdAt, 'pp')}</Text>
+ </StatusLight>
+ <Row alignItems="center" gap="2">
+ <Icon>{eventName ? <Lightning /> : <Eye />}</Icon>
+ <Text wrap="nowrap">
+ {eventName
+ ? formatMessage(labels.triggeredEvent)
+ : formatMessage(labels.viewedPage)}
+ </Text>
+ <Text weight="bold" style={{ maxWidth: isMobile ? '400px' : null }} truncate>
+ {eventName || urlPath}
+ </Text>
+ {hasData > 0 && <PropertiesButton websiteId={websiteId} eventId={eventId} />}
+ </Row>
+ </Row>
+ </Column>
+ );
+ })}
+ </Column>
+ </LoadingPanel>
+ );
+}
+
+const PropertiesButton = props => {
+ return (
+ <DialogTrigger>
+ <Button variant="quiet">
+ <Row alignItems="center" gap>
+ <Icon>
+ <FileText />
+ </Icon>
+ </Row>
+ </Button>
+ <Popover placement="right">
+ <Dialog>
+ <EventData {...props} />
+ </Dialog>
+ </Popover>
+ </DialogTrigger>
+ );
+};
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionData.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionData.tsx
new file mode 100644
index 0000000..7c82c17
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionData.tsx
@@ -0,0 +1,32 @@
+import { Box, Column, Label, Row, Text } from '@umami/react-zen';
+import { Empty } from '@/components/common/Empty';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useSessionDataQuery } from '@/components/hooks';
+import { DATA_TYPES } from '@/lib/constants';
+
+export function SessionData({ websiteId, sessionId }: { websiteId: string; sessionId: string }) {
+ const { data, isLoading, error } = useSessionDataQuery(websiteId, sessionId);
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ {!data?.length && <Empty />}
+ <Column gap="6">
+ {data?.map(({ dataKey, dataType, stringValue }) => {
+ return (
+ <Column key={dataKey}>
+ <Label>{dataKey}</Label>
+ <Row alignItems="center" gap>
+ <Text>{stringValue}</Text>
+ <Box paddingY="1" paddingX="2" border borderRadius borderColor="5">
+ <Text color="muted" size="1">
+ {DATA_TYPES[dataType]}
+ </Text>
+ </Box>
+ </Row>
+ </Column>
+ );
+ })}
+ </Column>
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionInfo.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionInfo.tsx
new file mode 100644
index 0000000..f15e6ee
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionInfo.tsx
@@ -0,0 +1,85 @@
+import { Column, Grid, Icon, Label, Row } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { DateDistance } from '@/components/common/DateDistance';
+import { TypeIcon } from '@/components/common/TypeIcon';
+import { useFormat, useLocale, useMessages, useRegionNames } from '@/components/hooks';
+import { Calendar, KeyRound, Landmark, MapPin } from '@/components/icons';
+
+export function SessionInfo({ data }) {
+ const { locale } = useLocale();
+ const { formatMessage, labels } = useMessages();
+ const { formatValue } = useFormat();
+ const { getRegionName } = useRegionNames(locale);
+
+ return (
+ <Grid columns="repeat(auto-fit, minmax(200px, 1fr)" gap>
+ <Info label={formatMessage(labels.distinctId)} icon={<KeyRound />}>
+ {data?.distinctId}
+ </Info>
+
+ <Info label={formatMessage(labels.lastSeen)} icon={<Calendar />}>
+ <DateDistance date={new Date(data.lastAt)} />
+ </Info>
+
+ <Info label={formatMessage(labels.firstSeen)} icon={<Calendar />}>
+ <DateDistance date={new Date(data.firstAt)} />
+ </Info>
+
+ <Info
+ label={formatMessage(labels.country)}
+ icon={<TypeIcon type="country" value={data?.country} />}
+ >
+ {formatValue(data?.country, 'country')}
+ </Info>
+
+ <Info label={formatMessage(labels.region)} icon={<MapPin />}>
+ {getRegionName(data?.region)}
+ </Info>
+
+ <Info label={formatMessage(labels.city)} icon={<Landmark />}>
+ {data?.city}
+ </Info>
+
+ <Info
+ label={formatMessage(labels.browser)}
+ icon={<TypeIcon type="browser" value={data?.browser} />}
+ >
+ {formatValue(data?.browser, 'browser')}
+ </Info>
+
+ <Info
+ label={formatMessage(labels.os)}
+ icon={<TypeIcon type="os" value={data?.os?.toLowerCase()?.replaceAll(/\W/g, '-')} />}
+ >
+ {formatValue(data?.os, 'os')}
+ </Info>
+
+ <Info
+ label={formatMessage(labels.device)}
+ icon={<TypeIcon type="device" value={data?.device} />}
+ >
+ {formatValue(data?.device, 'device')}
+ </Info>
+ </Grid>
+ );
+}
+
+const Info = ({
+ label,
+ icon,
+ children,
+}: {
+ label: string;
+ icon?: ReactNode;
+ children: ReactNode;
+}) => {
+ return (
+ <Column>
+ <Label>{label}</Label>
+ <Row alignItems="center" gap>
+ {icon && <Icon>{icon}</Icon>}
+ {children || '—'}
+ </Row>
+ </Column>
+ );
+};
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionModal.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionModal.tsx
new file mode 100644
index 0000000..d658038
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionModal.tsx
@@ -0,0 +1,41 @@
+import { Column, Dialog, Modal, type ModalProps } from '@umami/react-zen';
+import { SessionProfile } from '@/app/(main)/websites/[websiteId]/sessions/SessionProfile';
+import { useNavigation } from '@/components/hooks';
+
+export interface SessionModalProps extends ModalProps {
+ websiteId: string;
+}
+
+export function SessionModal({ websiteId, ...props }: SessionModalProps) {
+ const {
+ router,
+ query: { session },
+ updateParams,
+ } = useNavigation();
+ const handleOpenChange = (isOpen: boolean) => {
+ if (!isOpen) {
+ router.push(updateParams({ session: undefined }));
+ }
+ };
+
+ return (
+ <Modal
+ placement="bottom"
+ offset="80px"
+ isOpen={!!session}
+ onOpenChange={handleOpenChange}
+ isDismissable
+ {...props}
+ >
+ <Column height="100%" maxWidth="1320px" style={{ margin: '0 auto' }}>
+ <Dialog variant="sheet">
+ {({ close }) => (
+ <Column padding="6">
+ <SessionProfile websiteId={websiteId} sessionId={session} onClose={() => close()} />
+ </Column>
+ )}
+ </Dialog>
+ </Column>
+ </Modal>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx
new file mode 100644
index 0000000..6624d43
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx
@@ -0,0 +1,84 @@
+import {
+ Button,
+ Column,
+ Icon,
+ Row,
+ Tab,
+ TabList,
+ TabPanel,
+ Tabs,
+ TextField,
+} from '@umami/react-zen';
+import { X } from 'lucide-react';
+import { Avatar } from '@/components/common/Avatar';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useMessages, useWebsiteSessionQuery } from '@/components/hooks';
+import { SessionActivity } from './SessionActivity';
+import { SessionData } from './SessionData';
+import { SessionInfo } from './SessionInfo';
+import { SessionStats } from './SessionStats';
+
+export function SessionProfile({
+ websiteId,
+ sessionId,
+ onClose,
+}: {
+ websiteId: string;
+ sessionId: string;
+ onClose?: () => void;
+}) {
+ const { data, isLoading, error } = useWebsiteSessionQuery(websiteId, sessionId);
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <LoadingPanel
+ data={data}
+ isLoading={isLoading}
+ error={error}
+ loadingIcon="spinner"
+ loadingPlacement="absolute"
+ >
+ {data && (
+ <Column gap>
+ {onClose && (
+ <Row justifyContent="flex-end">
+ <Button onPress={onClose} variant="quiet">
+ <Icon>
+ <X />
+ </Icon>
+ </Button>
+ </Row>
+ )}
+ <Column gap="6">
+ <Row justifyContent="center" alignItems="center" gap="6">
+ <Avatar seed={data?.id} size={128} />
+ <Column width="360px">
+ <TextField label="ID" value={data?.id} allowCopy />
+ </Column>
+ </Row>
+ <SessionStats data={data} />
+ <SessionInfo data={data} />
+
+ <Tabs>
+ <TabList>
+ <Tab id="activity">{formatMessage(labels.activity)}</Tab>
+ <Tab id="properties">{formatMessage(labels.properties)}</Tab>
+ </TabList>
+ <TabPanel id="activity">
+ <SessionActivity
+ websiteId={websiteId}
+ sessionId={sessionId}
+ startDate={data?.firstAt}
+ endDate={data?.lastAt}
+ />
+ </TabPanel>
+ <TabPanel id="properties">
+ <SessionData sessionId={sessionId} websiteId={websiteId} />
+ </TabPanel>
+ </Tabs>
+ </Column>
+ </Column>
+ )}
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx
new file mode 100644
index 0000000..1693d05
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx
@@ -0,0 +1,97 @@
+import { Column, Grid, ListItem, Select } from '@umami/react-zen';
+import { useMemo, useState } from 'react';
+import { PieChart } from '@/components/charts/PieChart';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import {
+ useMessages,
+ useSessionDataPropertiesQuery,
+ useSessionDataValuesQuery,
+} from '@/components/hooks';
+import { ListTable } from '@/components/metrics/ListTable';
+import { CHART_COLORS } from '@/lib/constants';
+
+export function SessionProperties({ websiteId }: { websiteId: string }) {
+ const [propertyName, setPropertyName] = useState('');
+ const { formatMessage, labels } = useMessages();
+ const { data, isLoading, isFetching, error } = useSessionDataPropertiesQuery(websiteId);
+
+ const properties: string[] = data?.map(e => e.propertyName);
+
+ return (
+ <LoadingPanel
+ isLoading={isLoading}
+ isFetching={isFetching}
+ data={data}
+ error={error}
+ minHeight="300px"
+ >
+ <Column gap="6">
+ {data && (
+ <Grid columns="repeat(auto-fill, minmax(300px, 1fr))" gap>
+ <Select
+ label={formatMessage(labels.event)}
+ value={propertyName}
+ onChange={setPropertyName}
+ placeholder=""
+ >
+ {properties?.map(p => (
+ <ListItem key={p} id={p}>
+ {p}
+ </ListItem>
+ ))}
+ </Select>
+ </Grid>
+ )}
+ {propertyName && <SessionValues websiteId={websiteId} propertyName={propertyName} />}
+ </Column>
+ </LoadingPanel>
+ );
+}
+
+const SessionValues = ({ websiteId, propertyName }) => {
+ const { data, isLoading, isFetching, error } = useSessionDataValuesQuery(websiteId, propertyName);
+
+ const propertySum = useMemo(() => {
+ return data?.reduce((sum, { total }) => sum + total, 0) ?? 0;
+ }, [data]);
+
+ const chartData = useMemo(() => {
+ if (!propertyName || !data) return null;
+ return {
+ labels: data.map(({ value }) => value),
+ datasets: [
+ {
+ data: data.map(({ total }) => total),
+ backgroundColor: CHART_COLORS,
+ borderWidth: 0,
+ },
+ ],
+ };
+ }, [propertyName, data]);
+
+ const tableData = useMemo(() => {
+ if (!propertyName || !data || propertySum === 0) return [];
+ return data.map(({ value, total }) => ({
+ label: value,
+ count: total,
+ percent: 100 * (total / propertySum),
+ }));
+ }, [propertyName, data, propertySum]);
+
+ return (
+ <LoadingPanel
+ isLoading={isLoading}
+ isFetching={isFetching}
+ data={data}
+ error={error}
+ minHeight="300px"
+ >
+ {data && (
+ <Grid columns="1fr 1fr" gap>
+ <ListTable title={propertyName} data={tableData} />
+ <PieChart type="doughnut" chartData={chartData} />
+ </Grid>
+ )}
+ </LoadingPanel>
+ );
+};
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionStats.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionStats.tsx
new file mode 100644
index 0000000..e25be9a
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionStats.tsx
@@ -0,0 +1,21 @@
+import { useMessages } from '@/components/hooks';
+import { MetricCard } from '@/components/metrics/MetricCard';
+import { MetricsBar } from '@/components/metrics/MetricsBar';
+import { formatShortTime } from '@/lib/format';
+
+export function SessionStats({ data }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <MetricsBar>
+ <MetricCard label={formatMessage(labels.visits)} value={data?.visits} />
+ <MetricCard label={formatMessage(labels.views)} value={data?.views} />
+ <MetricCard label={formatMessage(labels.events)} value={data?.events} />
+ <MetricCard
+ label={formatMessage(labels.visitDuration)}
+ value={data?.totaltime / data?.visits}
+ formatValue={n => `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`}
+ />
+ </MetricsBar>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx
new file mode 100644
index 0000000..b1b9f65
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx
@@ -0,0 +1,15 @@
+import { DataGrid } from '@/components/common/DataGrid';
+import { useWebsiteSessionsQuery } from '@/components/hooks';
+import { SessionsTable } from './SessionsTable';
+
+export function SessionsDataTable({ websiteId }: { websiteId?: string; teamId?: string }) {
+ const queryResult = useWebsiteSessionsQuery(websiteId);
+
+ return (
+ <DataGrid query={queryResult} allowPaging allowSearch>
+ {({ data }) => {
+ return <SessionsTable data={data} />;
+ }}
+ </DataGrid>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx
new file mode 100644
index 0000000..c8317a2
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx
@@ -0,0 +1,40 @@
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useMessages } from '@/components/hooks';
+import { useWebsiteSessionStatsQuery } from '@/components/hooks/queries/useWebsiteSessionStatsQuery';
+import { MetricCard } from '@/components/metrics/MetricCard';
+import { MetricsBar } from '@/components/metrics/MetricsBar';
+import { formatLongNumber } from '@/lib/format';
+
+export function SessionsMetricsBar({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+ const { data, isLoading, isFetching, error } = useWebsiteSessionStatsQuery(websiteId);
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
+ {data && (
+ <MetricsBar>
+ <MetricCard
+ value={data?.visitors?.value}
+ label={formatMessage(labels.visitors)}
+ formatValue={formatLongNumber}
+ />
+ <MetricCard
+ value={data?.visits?.value}
+ label={formatMessage(labels.visits)}
+ formatValue={formatLongNumber}
+ />
+ <MetricCard
+ value={data?.pageviews?.value}
+ label={formatMessage(labels.views)}
+ formatValue={formatLongNumber}
+ />
+ <MetricCard
+ value={data?.countries?.value}
+ label={formatMessage(labels.countries)}
+ formatValue={formatLongNumber}
+ />
+ </MetricsBar>
+ )}
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx
new file mode 100644
index 0000000..8e9d2f2
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx
@@ -0,0 +1,43 @@
+'use client';
+import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
+import { type Key, useState } from 'react';
+import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { Panel } from '@/components/common/Panel';
+import { useMessages } from '@/components/hooks';
+import { getItem, setItem } from '@/lib/storage';
+import { SessionProperties } from './SessionProperties';
+import { SessionsDataTable } from './SessionsDataTable';
+
+const KEY_NAME = 'umami.sessions.tab';
+
+export function SessionsPage({ websiteId }) {
+ const [tab, setTab] = useState(getItem(KEY_NAME) || 'activity');
+ const { formatMessage, labels } = useMessages();
+
+ const handleSelect = (value: Key) => {
+ setItem(KEY_NAME, value);
+ setTab(value);
+ };
+
+ return (
+ <Column gap="3">
+ <WebsiteControls websiteId={websiteId} />
+ <Panel>
+ <Tabs selectedKey={tab} onSelectionChange={handleSelect}>
+ <TabList>
+ <Tab id="activity">{formatMessage(labels.activity)}</Tab>
+ <Tab id="properties">{formatMessage(labels.properties)}</Tab>
+ </TabList>
+ <TabPanel id="activity">
+ <SessionsDataTable websiteId={websiteId} />
+ </TabPanel>
+ <TabPanel id="properties">
+ <SessionProperties websiteId={websiteId} />
+ </TabPanel>
+ </Tabs>
+ </Panel>
+ <SessionModal websiteId={websiteId} />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx
new file mode 100644
index 0000000..5d3bb37
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx
@@ -0,0 +1,58 @@
+import { DataColumn, DataTable, type DataTableProps } from '@umami/react-zen';
+import Link from 'next/link';
+import { Avatar } from '@/components/common/Avatar';
+import { DateDistance } from '@/components/common/DateDistance';
+import { TypeIcon } from '@/components/common/TypeIcon';
+import { useFormat, useMessages, useNavigation } from '@/components/hooks';
+
+export function SessionsTable(props: DataTableProps) {
+ const { formatMessage, labels } = useMessages();
+ const { formatValue } = useFormat();
+ const { updateParams } = useNavigation();
+
+ return (
+ <DataTable {...props}>
+ <DataColumn id="id" label={formatMessage(labels.session)} width="100px">
+ {(row: any) => (
+ <Link href={updateParams({ session: row.id })}>
+ <Avatar seed={row.id} size={32} />
+ </Link>
+ )}
+ </DataColumn>
+ <DataColumn id="visits" label={formatMessage(labels.visits)} width="80px" />
+ <DataColumn id="views" label={formatMessage(labels.views)} width="80px" />
+ <DataColumn id="country" label={formatMessage(labels.country)}>
+ {(row: any) => (
+ <TypeIcon type="country" value={row.country}>
+ {formatValue(row.country, 'country')}
+ </TypeIcon>
+ )}
+ </DataColumn>
+ <DataColumn id="city" label={formatMessage(labels.city)} />
+ <DataColumn id="browser" label={formatMessage(labels.browser)}>
+ {(row: any) => (
+ <TypeIcon type="browser" value={row.browser}>
+ {formatValue(row.browser, 'browser')}
+ </TypeIcon>
+ )}
+ </DataColumn>
+ <DataColumn id="os" label={formatMessage(labels.os)}>
+ {(row: any) => (
+ <TypeIcon type="os" value={row.os}>
+ {formatValue(row.os, 'os')}
+ </TypeIcon>
+ )}
+ </DataColumn>
+ <DataColumn id="device" label={formatMessage(labels.device)}>
+ {(row: any) => (
+ <TypeIcon type="device" value={row.device}>
+ {formatValue(row.device, 'device')}
+ </TypeIcon>
+ )}
+ </DataColumn>
+ <DataColumn id="lastAt" label={formatMessage(labels.lastSeen)}>
+ {(row: any) => <DateDistance date={new Date(row.createdAt)} />}
+ </DataColumn>
+ </DataTable>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/page.tsx b/src/app/(main)/websites/[websiteId]/sessions/page.tsx
new file mode 100644
index 0000000..221ab71
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { SessionsPage } from './SessionsPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <SessionsPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Sessions',
+};
diff --git a/src/app/(main)/websites/[websiteId]/settings/SettingsPage.tsx b/src/app/(main)/websites/[websiteId]/settings/SettingsPage.tsx
new file mode 100644
index 0000000..468f250
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/SettingsPage.tsx
@@ -0,0 +1,6 @@
+'use client';
+import { WebsiteSettingsPage } from '@/app/(main)/settings/websites/[websiteId]/WebsiteSettingsPage';
+
+export function SettingsPage({ websiteId }: { websiteId: string }) {
+ return <WebsiteSettingsPage websiteId={websiteId} />;
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteData.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteData.tsx
new file mode 100644
index 0000000..21cd613
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteData.tsx
@@ -0,0 +1,104 @@
+import { Button, Column, Dialog, DialogTrigger, Modal } from '@umami/react-zen';
+import { ActionForm } from '@/components/common/ActionForm';
+import {
+ useLoginQuery,
+ useMessages,
+ useModified,
+ useNavigation,
+ useUserTeamsQuery,
+} from '@/components/hooks';
+import { ROLES } from '@/lib/constants';
+import { WebsiteDeleteForm } from './WebsiteDeleteForm';
+import { WebsiteResetForm } from './WebsiteResetForm';
+import { WebsiteTransferForm } from './WebsiteTransferForm';
+
+export function WebsiteData({ websiteId, onSave }: { websiteId: string; onSave?: () => void }) {
+ const { formatMessage, labels, messages } = useMessages();
+ const { user } = useLoginQuery();
+ const { touch } = useModified();
+ const { router, pathname, teamId, renderUrl } = useNavigation();
+ const { data: teams } = useUserTeamsQuery(user.id);
+ const isAdmin = pathname.startsWith('/admin');
+
+ const canTransferWebsite =
+ (
+ (!teamId &&
+ teams?.data?.filter(({ members }) =>
+ members.find(
+ ({ role, userId }) =>
+ [ROLES.teamOwner, ROLES.teamManager].includes(role) && userId === user.id,
+ ),
+ )) ||
+ []
+ ).length > 0 ||
+ (teamId &&
+ !!teams?.data
+ ?.find(({ id }) => id === teamId)
+ ?.members.find(({ role, userId }) => role === ROLES.teamOwner && userId === user.id));
+
+ const handleSave = () => {
+ touch('websites');
+ onSave?.();
+ router.push(renderUrl(`/websites`));
+ };
+
+ const handleReset = async () => {
+ onSave?.();
+ };
+
+ return (
+ <Column gap="6">
+ {!isAdmin && (
+ <ActionForm
+ label={formatMessage(labels.transferWebsite)}
+ description={formatMessage(messages.transferWebsite)}
+ >
+ <DialogTrigger>
+ <Button isDisabled={!canTransferWebsite}>{formatMessage(labels.transfer)}</Button>
+ <Modal>
+ <Dialog title={formatMessage(labels.transferWebsite)} style={{ width: 400 }}>
+ {({ close }) => (
+ <WebsiteTransferForm websiteId={websiteId} onSave={handleSave} onClose={close} />
+ )}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ </ActionForm>
+ )}
+
+ <ActionForm
+ label={formatMessage(labels.resetWebsite)}
+ description={formatMessage(messages.resetWebsiteWarning)}
+ >
+ <DialogTrigger>
+ <Button>{formatMessage(labels.reset)}</Button>
+ <Modal>
+ <Dialog title={formatMessage(labels.resetWebsite)} style={{ width: 400 }}>
+ {({ close }) => (
+ <WebsiteResetForm websiteId={websiteId} onSave={handleReset} onClose={close} />
+ )}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ </ActionForm>
+
+ <ActionForm
+ label={formatMessage(labels.deleteWebsite)}
+ description={formatMessage(messages.deleteWebsiteWarning)}
+ >
+ <DialogTrigger>
+ <Button data-test="button-delete" variant="danger">
+ {formatMessage(labels.delete)}
+ </Button>
+ <Modal>
+ <Dialog title={formatMessage(labels.deleteWebsite)} style={{ width: 400 }}>
+ {({ close }) => (
+ <WebsiteDeleteForm websiteId={websiteId} onSave={handleSave} onClose={close} />
+ )}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ </ActionForm>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm.tsx
new file mode 100644
index 0000000..2fc0276
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm.tsx
@@ -0,0 +1,40 @@
+import { TypeConfirmationForm } from '@/components/common/TypeConfirmationForm';
+import { useDeleteQuery, useMessages } from '@/components/hooks';
+
+const CONFIRM_VALUE = 'DELETE';
+
+export function WebsiteDeleteForm({
+ websiteId,
+ onSave,
+ onClose,
+}: {
+ websiteId: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { mutateAsync, isPending, error, touch } = useDeleteQuery(`/websites/${websiteId}`);
+
+ const handleConfirm = async () => {
+ await mutateAsync(null, {
+ onSuccess: async () => {
+ touch('websites');
+ touch(`websites:${websiteId}`);
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ return (
+ <TypeConfirmationForm
+ confirmationValue={CONFIRM_VALUE}
+ onConfirm={handleConfirm}
+ onClose={onClose}
+ isLoading={isPending}
+ error={error}
+ buttonLabel={formatMessage(labels.delete)}
+ buttonVariant="danger"
+ />
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteEditForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteEditForm.tsx
new file mode 100644
index 0000000..4ae819e
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteEditForm.tsx
@@ -0,0 +1,55 @@
+import { Form, FormButtons, FormField, FormSubmitButton, TextField } from '@umami/react-zen';
+import { useMessages, useUpdateQuery, useWebsite } from '@/components/hooks';
+import { DOMAIN_REGEX } from '@/lib/constants';
+
+export function WebsiteEditForm({ websiteId, onSave }: { websiteId: string; onSave?: () => void }) {
+ const website = useWebsite();
+ const { formatMessage, labels, messages, getErrorMessage } = useMessages();
+ const { mutateAsync, error, touch, toast } = useUpdateQuery(`/websites/${websiteId}`);
+
+ const handleSubmit = async (data: any) => {
+ await mutateAsync(data, {
+ onSuccess: async () => {
+ toast(formatMessage(messages.saved));
+ touch('websites');
+ touch(`website:${website.id}`);
+ onSave?.();
+ },
+ });
+ };
+
+ return (
+ <Form onSubmit={handleSubmit} error={getErrorMessage(error)} values={website}>
+ <FormField name="id" label={formatMessage(labels.websiteId)}>
+ <TextField data-test="text-field-websiteId" value={website?.id} isReadOnly allowCopy />
+ </FormField>
+ <FormField
+ label={formatMessage(labels.name)}
+ data-test="input-name"
+ name="name"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField />
+ </FormField>
+ <FormField
+ label={formatMessage(labels.domain)}
+ data-test="input-domain"
+ name="domain"
+ rules={{
+ required: formatMessage(labels.required),
+ pattern: {
+ value: DOMAIN_REGEX,
+ message: formatMessage(messages.invalidDomain),
+ },
+ }}
+ >
+ <TextField />
+ </FormField>
+ <FormButtons>
+ <FormSubmitButton data-test="button-submit" variant="primary">
+ {formatMessage(labels.save)}
+ </FormSubmitButton>
+ </FormButtons>
+ </Form>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteResetForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteResetForm.tsx
new file mode 100644
index 0000000..d791bc9
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteResetForm.tsx
@@ -0,0 +1,37 @@
+import { TypeConfirmationForm } from '@/components/common/TypeConfirmationForm';
+import { useMessages, useUpdateQuery } from '@/components/hooks';
+
+const CONFIRM_VALUE = 'RESET';
+
+export function WebsiteResetForm({
+ websiteId,
+ onSave,
+ onClose,
+}: {
+ websiteId: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { mutateAsync, isPending, error } = useUpdateQuery(`/websites/${websiteId}/reset`);
+
+ const handleConfirm = async () => {
+ await mutateAsync(null, {
+ onSuccess: async () => {
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ return (
+ <TypeConfirmationForm
+ confirmationValue={CONFIRM_VALUE}
+ onConfirm={handleConfirm}
+ onClose={onClose}
+ isLoading={isPending}
+ error={error}
+ buttonLabel={formatMessage(labels.reset)}
+ />
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx
new file mode 100644
index 0000000..3970cdb
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx
@@ -0,0 +1,28 @@
+import { Column } from '@umami/react-zen';
+import { Panel } from '@/components/common/Panel';
+import { useWebsite } from '@/components/hooks';
+import { WebsiteData } from './WebsiteData';
+import { WebsiteEditForm } from './WebsiteEditForm';
+import { WebsiteShareForm } from './WebsiteShareForm';
+import { WebsiteTrackingCode } from './WebsiteTrackingCode';
+
+export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal?: boolean }) {
+ const website = useWebsite();
+
+ return (
+ <Column gap="6">
+ <Panel>
+ <WebsiteEditForm websiteId={websiteId} />
+ </Panel>
+ <Panel>
+ <WebsiteTrackingCode websiteId={websiteId} />
+ </Panel>
+ <Panel>
+ <WebsiteShareForm websiteId={websiteId} shareId={website.shareId} />
+ </Panel>
+ <Panel>
+ <WebsiteData websiteId={websiteId} />
+ </Panel>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader.tsx
new file mode 100644
index 0000000..99977a0
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader.tsx
@@ -0,0 +1,22 @@
+import { IconLabel, Row } from '@umami/react-zen';
+import Link from 'next/link';
+import { PageHeader } from '@/components/common/PageHeader';
+import { useMessages, useNavigation, useWebsite } from '@/components/hooks';
+import { ArrowLeft, Globe } from '@/components/icons';
+
+export function WebsiteSettingsHeader() {
+ const website = useWebsite();
+ const { formatMessage, labels } = useMessages();
+ const { renderUrl } = useNavigation();
+
+ return (
+ <>
+ <Row marginTop="6">
+ <Link href={renderUrl(`/websites/${website.id}`)}>
+ <IconLabel icon={<ArrowLeft />} label={formatMessage(labels.website)} />
+ </Link>
+ </Row>
+ <PageHeader title={website?.name} description={website?.domain} icon={<Globe />} />
+ </>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx
new file mode 100644
index 0000000..56c6f43
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx
@@ -0,0 +1,93 @@
+import {
+ Button,
+ Column,
+ Form,
+ FormButtons,
+ FormSubmitButton,
+ IconLabel,
+ Label,
+ Row,
+ Switch,
+ TextField,
+} from '@umami/react-zen';
+import { RefreshCcw } from 'lucide-react';
+import { useState } from 'react';
+import { useConfig, useMessages, useUpdateQuery } from '@/components/hooks';
+import { getRandomChars } from '@/lib/generate';
+
+const generateId = () => getRandomChars(16);
+
+export interface WebsiteShareFormProps {
+ websiteId: string;
+ shareId?: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}
+
+export function WebsiteShareForm({ websiteId, shareId, onSave, onClose }: WebsiteShareFormProps) {
+ const { formatMessage, labels, messages, getErrorMessage } = useMessages();
+ const [currentId, setCurrentId] = useState(shareId);
+ const { mutateAsync, error, touch, toast } = useUpdateQuery(`/websites/${websiteId}`);
+ const { cloudMode } = useConfig();
+
+ const getUrl = (shareId: string) => {
+ if (cloudMode) {
+ return `${process.env.cloudUrl}/share/${shareId}`;
+ }
+
+ return `${window?.location.origin}${process.env.basePath || ''}/share/${shareId}`;
+ };
+
+ const url = getUrl(currentId);
+
+ const handleGenerate = () => {
+ setCurrentId(generateId());
+ };
+
+ const handleSwitch = () => {
+ setCurrentId(currentId ? null : generateId());
+ };
+
+ const handleSave = async () => {
+ const data = {
+ shareId: currentId,
+ };
+ await mutateAsync(data, {
+ onSuccess: async () => {
+ toast(formatMessage(messages.saved));
+ touch(`website:${websiteId}`);
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ return (
+ <Form onSubmit={handleSave} error={getErrorMessage(error)} values={{ url }}>
+ <Column gap>
+ <Switch isSelected={!!currentId} onChange={handleSwitch}>
+ {formatMessage(labels.enableShareUrl)}
+ </Switch>
+ {currentId && (
+ <Row alignItems="flex-end" gap>
+ <Column flexGrow={1}>
+ <Label>{formatMessage(labels.shareUrl)}</Label>
+ <TextField value={url} isReadOnly allowCopy />
+ </Column>
+ <Column>
+ <Button onPress={handleGenerate}>
+ <IconLabel icon={<RefreshCcw />} label={formatMessage(labels.regenerate)} />
+ </Button>
+ </Column>
+ </Row>
+ )}
+ <FormButtons justifyContent="flex-end">
+ <Row alignItems="center" gap>
+ {onClose && <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>}
+ <FormSubmitButton isDisabled={false}>{formatMessage(labels.save)}</FormSubmitButton>
+ </Row>
+ </FormButtons>
+ </Column>
+ </Form>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode.tsx
new file mode 100644
index 0000000..d24f948
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode.tsx
@@ -0,0 +1,40 @@
+import { Column, Label, Text, TextField } from '@umami/react-zen';
+import { useConfig, useMessages } from '@/components/hooks';
+
+const SCRIPT_NAME = 'script.js';
+
+export function WebsiteTrackingCode({
+ websiteId,
+ hostUrl,
+}: {
+ websiteId: string;
+ hostUrl?: string;
+}) {
+ const { formatMessage, messages, labels } = useMessages();
+ const config = useConfig();
+
+ const trackerScriptName =
+ config?.trackerScriptName?.split(',')?.map((n: string) => n.trim())?.[0] || SCRIPT_NAME;
+
+ const getUrl = () => {
+ if (config?.cloudMode) {
+ return `${process.env.cloudUrl}/${trackerScriptName}`;
+ }
+
+ return `${hostUrl || window?.location?.origin || ''}${
+ process.env.basePath || ''
+ }/${trackerScriptName}`;
+ };
+
+ const url = trackerScriptName?.startsWith('http') ? trackerScriptName : getUrl();
+
+ const code = `<script defer src="${url}" data-website-id="${websiteId}"></script>`;
+
+ return (
+ <Column gap>
+ <Label>{formatMessage(labels.trackingCode)}</Label>
+ <Text color="muted">{formatMessage(messages.trackingCode)}</Text>
+ <TextField value={code} isReadOnly allowCopy asTextArea resize="none" />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteTransferForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteTransferForm.tsx
new file mode 100644
index 0000000..8af4f05
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteTransferForm.tsx
@@ -0,0 +1,102 @@
+import {
+ Button,
+ Form,
+ FormButtons,
+ FormField,
+ FormSubmitButton,
+ ListItem,
+ Loading,
+ Select,
+ Text,
+} from '@umami/react-zen';
+import { type Key, useState } from 'react';
+import {
+ useLoginQuery,
+ useMessages,
+ useUpdateQuery,
+ useUserTeamsQuery,
+ useWebsite,
+} from '@/components/hooks';
+import { ROLES } from '@/lib/constants';
+
+export function WebsiteTransferForm({
+ websiteId,
+ onSave,
+ onClose,
+}: {
+ websiteId: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { user } = useLoginQuery();
+ const website = useWebsite();
+ const [teamId, setTeamId] = useState<string>(null);
+ const { formatMessage, labels, messages, getErrorMessage } = useMessages();
+ const { mutateAsync, error, isPending } = useUpdateQuery(`/websites/${websiteId}/transfer`);
+ const { data: teams, isLoading } = useUserTeamsQuery(user.id);
+ const isTeamWebsite = !!website?.teamId;
+
+ const items =
+ teams?.data?.filter(({ members }) =>
+ members.some(
+ ({ role, userId }) =>
+ [ROLES.teamOwner, ROLES.teamManager].includes(role) && userId === user.id,
+ ),
+ ) || [];
+
+ const handleSubmit = async () => {
+ await mutateAsync(
+ {
+ userId: website.teamId ? user.id : undefined,
+ teamId: website.userId ? teamId : undefined,
+ },
+ {
+ onSuccess: async () => {
+ onSave?.();
+ onClose?.();
+ },
+ },
+ );
+ };
+
+ const handleChange = (key: Key) => {
+ setTeamId(key as string);
+ };
+
+ if (isLoading) {
+ return <Loading icon="dots" placement="center" />;
+ }
+
+ return (
+ <Form onSubmit={handleSubmit} error={getErrorMessage(error)} values={{ teamId }}>
+ <Text>
+ {formatMessage(
+ isTeamWebsite ? messages.transferTeamWebsiteToUser : messages.transferUserWebsiteToTeam,
+ )}
+ </Text>
+ <FormField name="teamId">
+ {!isTeamWebsite && (
+ <Select onSelectionChange={handleChange} selectedKey={teamId}>
+ {items.map(({ id, name }) => {
+ return (
+ <ListItem key={`${id}`} id={`${id}`}>
+ {name}
+ </ListItem>
+ );
+ })}
+ </Select>
+ )}
+ </FormField>
+ <FormButtons>
+ <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
+ <FormSubmitButton
+ variant="primary"
+ isPending={isPending}
+ isDisabled={!isTeamWebsite && !teamId}
+ >
+ {formatMessage(labels.transfer)}
+ </FormSubmitButton>
+ </FormButtons>
+ </Form>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/page.tsx b/src/app/(main)/websites/[websiteId]/settings/page.tsx
new file mode 100644
index 0000000..a26d14f
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { SettingsPage } from './SettingsPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <SettingsPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Settings',
+};